Files
IdeA/frontend/src/features/agents/CustomAgentChatView.test.tsx
Blomios 7007b2cc7e feat(backend,frontend): tâches planifiées projet — tool MCP idea_scheduled_task_* et menu utilisateur #305, fix transcript lié indisponible après reboot #314 (QA verte)
#305: nouvel agrégat durable ScheduledProjectTask dans domain (scheduled_task.rs, ids, ports ScheduledProjectTaskStore), source de vérité <app_data_dir>/scheduled-project-tasks/<projectId>.json, schedules bornés V1 interval/daily/weekly avec fuseau, cycle À approuver/Active/Suspendue avec approbation obligatoire des tâches demandées par agent; use cases application/scheduled_task (list/create/update/delete/run_now/reconcile/fire) réutilisant BackgroundTask pour les runs avec distinction planifié/manuel; store infrastructure dédié + scheduler reconcile au boot sans rattrapage en rafale, cwd confiné au projet; 7 tools MCP idea_scheduled_task_* (list/read lecture seule, create/update/delete/run_now/set_enabled write-actions soumises aux permissions natives); commandes Tauri + routes HTTP aux contrats DTO identiques (camelCase nextRunAtMs/lastRunAtMs/lastTaskId/createdBy/updatedBy); frontend: gateways Tauri/HTTP/mock + types ports/domain, surface projet 'Tâches planifiées' (liste utilisateur+agent unifiée, création/édition avec récapitulatif avant activation, approbation/refus, exécuter maintenant, suspendre/reprendre, suppression), catalogage surface web (webSurfaceCatalog/viewPlacement/ViewPanelBody/WebWorkspace); docs/scheduled-project-tasks.md et sdk docs/services.md (047c38b: pas un service plugin V1, pas d'émulation via cron système).

#314: correction de 'Conversation liée — transcript indisponible' après reboot: restauration du transcript lié côté application (orchestrator service, lifecycle, session_limit, background sink, terminal registry) et côté frontend (adapters agent/http frames/stream/requestResponse, workStateNormalization, CustomAgentChatView avec état et action utile), tests dto_chat/agent_lifecycle/structured_registry_d1/background_completion_sink étendus.

QA verte rapportée par QA sur feature/305-scheduled-project-tasks-mcp, sans bloquant, pour #305 et #314.

Hors périmètre worktree: tickets non liés, attachments/, scripts/; aucun push.
2026-09-11 10:32:01 +02:00

5717 lines
178 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
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("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" })),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
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("Son transcript 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("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("------- redemarrage de l'agent -------"),
).not.toBeNull();
expect(
screen.getByText("------- redemarrage de l'agent termine -------"),
).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",
message: "runtime unavailable",
});
});
expect(
screen.getByText("------- redemarrage de l'agent echoue -------"),
).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 custom/) 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 custom/,
)) 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 custom/,
)) 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 custom/,
)) 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 custom/) 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 custom/) as HTMLTextAreaElement;
fireEvent.keyDown(composer, { key: "ArrowUp" });
await waitFor(() => expect(composer.value).toBe("remember me"));
});
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 custom/), {
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 custom/), {
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 custom/) as HTMLTextAreaElement;
const handle = screen.getByRole("button", {
name: "redimensionner le composer 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 custom/).hasAttribute("disabled")).toBe(true);
act(() => {
secondReattach.resolve({ sessionId: "chat-session-1", scrollback });
});
expect(await screen.findByText("Question during graph view")).toBeTruthy();
expect(screen.getByText("Partial answer while away")).toBeTruthy();
expect(screen.queryByText(/Reprise effectuée:/)).toBeNull();
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true);
});
it("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 custom/), {
target: { value: "first turn" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() =>
expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1",
"first turn",
expect.any(Function),
),
);
expect(hasProjectCliInteraction("project-1")).toBe(true);
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
const stoppingLabel = await screen.findByText("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("Request stopped.")).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 custom/);
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 custom/);
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 custom/)).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 custom/), {
target: { value: "hello agent" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await screen.findByText("done");
expect(hasProjectCliInteraction("project-1")).toBe(false);
expect(screen.getAllByText("hello agent")).toHaveLength(1);
expect(screen.getByText("Terminé").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 custom/), {
target: { value: "first request" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await screen.findByText("final first request");
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
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 custom/), {
target: { value: "ship progress UI" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await screen.findByText("Progress");
expect(screen.getAllByTestId("chat-turn-marker")).toHaveLength(2);
expect(screen.getByText("turn.started")).toBeTruthy();
expect(screen.getByText("turn.completed")).toBeTruthy();
expect(screen.getByText("Progress").closest("[data-testid='chat-progress-status']")).toBeTruthy();
expect(screen.getByText("Analyse")).toBeTruthy();
expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy();
expect(screen.getByText("MCP")).toBeTruthy();
expect(screen.getByText("MCP").closest("[data-testid='chat-tool-progress']")).toBeTruthy();
expect(screen.getByText("idea_ticket_read")).toBeTruthy();
expect(screen.getByText("Worker ← QA")).toBeTruthy();
expect(screen.getByText("Worker ← QA").closest("[data-testid='chat-tool-progress']")).toBeTruthy();
expect(screen.getByText("Valider le rendu progress")).toBeTruthy();
expect((await screen.findAllByText("done")).length).toBeGreaterThan(0);
});
it("renders 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 custom/), {
target: { value: "first" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await screen.findByText("done first");
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
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 custom/), {
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("Tool").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 custom/), {
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("Progress").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 custom/), {
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 custom/), {
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 custom/), {
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 custom/), {
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 custom/), {
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 custom/), {
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 custom/), {
target: { value: "delegate after intent" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(emitChunk).not.toBeNull());
act(() => {
emitChunk?.({
kind: "progress",
progress: {
source: "providerNative",
kind: "message",
stage: "delta",
label: "Intention",
text: "Je vais d'abord regarder les outils MCP disponibles.",
provider: "codex",
nativeEvent: "agent.message.delta",
},
});
});
expect(
screen.getByText("Je vais d'abord regarder les outils MCP disponibles."),
).toBeTruthy();
expect(screen.queryByText("idea_ask_agent")).toBeNull();
act(() => {
emitChunk?.({
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "started",
label: "idea_ask_agent",
toolName: "idea_ask_agent",
},
});
});
const intent = screen.getByText(
"Je vais d'abord regarder les outils MCP disponibles.",
);
const delegation = screen.getByText("idea_ask_agent");
expect(
Boolean(intent.compareDocumentPosition(delegation) & Node.DOCUMENT_POSITION_FOLLOWING),
).toBe(true);
act(() => {
emitChunk?.({ kind: "final", content: "done" });
});
await screen.findByText("done");
expect(container.textContent).toContain("Je vais d'abord regarder");
});
it("deduplicates delegated tool progress from tap and authoritative stream", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
new Promise<void>((resolve) => {
emitChunk = (chunk: unknown) => {
onChunk(chunk);
if ((chunk as { kind?: string }).kind === "final") resolve();
};
}),
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "delegate once" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(emitChunk).not.toBeNull());
const progress = (source: "providerNative" | "ideaLocal", stage: "started" | "completed") => ({
kind: "progress" as const,
progress: {
source,
kind: source === "ideaLocal" ? ("mcp" as const) : ("tool" as const),
stage,
label: "idea_ask_agent",
text: stage === "started" ? "target=QA task=validate" : "reply delivered",
provider: source === "providerNative" ? "opencode" : undefined,
nativeEvent:
source === "providerNative"
? stage === "started"
? "tool_use"
: "tool_done"
: stage === "started"
? "conversation.item.added"
: "conversation.item.done",
toolName: "idea_ask_agent",
},
});
act(() => {
emitChunk?.(progress("providerNative", "started"));
emitChunk?.(progress("ideaLocal", "started"));
});
expect(screen.getByText("Worker → QA")).toBeTruthy();
expect(screen.getByText("Worker → QA").parentElement?.textContent).toContain("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("Tool")).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 custom/), {
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 custom/), {
target: { value: "read ticket" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(emitChunk).not.toBeNull());
const chunk = {
kind: "progress" as const,
progress: {
source: "ideaLocal" as const,
kind: "mcp" as const,
stage: "started" as const,
label: "idea_ticket_read",
toolName: "idea_ticket_read",
},
};
act(() => {
emitChunk?.(chunk);
emitChunk?.(chunk);
});
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
expect(screen.getAllByText("idea_ticket_read")).toHaveLength(1);
});
it("updates entity progress deltas in place", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
new Promise<void>((resolve) => {
emitChunk = (chunk: unknown) => {
onChunk(chunk);
if ((chunk as { kind?: string }).kind === "final") resolve();
};
}),
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "download model" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(emitChunk).not.toBeNull());
const progress = (stage: "started" | "delta", text: string) => ({
kind: "progress" as const,
progress: {
source: "providerNative" as const,
kind: "tool" as const,
stage,
label: "download_model",
toolName: "download_model",
text,
},
});
act(() => {
emitChunk?.(progress("started", "Downloading 10%"));
emitChunk?.(progress("delta", "Downloading 75%"));
});
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
expect(screen.queryByText("Downloading 10%")).toBeNull();
expect(screen.getByText("Downloading 75%")).toBeTruthy();
expect(screen.getByText("Tool").parentElement?.textContent).toContain("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 custom/), {
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 custom/), {
target: { value: "stream progress" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(emitChunk).not.toBeNull());
act(() => {
emitChunk?.({
kind: "progress" as const,
progress: {
source: "providerNative" as const,
kind: "tool" as const,
stage: "started" as const,
label: "stream_repo",
toolName: "stream_repo",
text: "Chunk received",
},
});
});
expect(screen.getByText("Chunk received")).toBeTruthy();
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
});
it("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 custom/), {
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 custom/), {
target: { value: "call tool" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(emitChunk).not.toBeNull());
const progress = (stage: "started" | "completed") => ({
kind: "progress" as const,
progress: {
source: "providerNative" as const,
kind: "tool" as const,
stage,
label: "search_repo",
toolName: "search_repo",
text: stage === "started" ? "Searching..." : "Found results",
},
});
act(() => {
emitChunk?.(progress("started"));
emitChunk?.(progress("completed"));
});
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
expect(screen.queryByText("Searching...")).toBeNull();
expect(screen.getByText("Found results")).toBeTruthy();
expect(screen.getByText("Tool").parentElement?.textContent).toContain("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 custom/), {
target: { value: "ship progress UI" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(emitChunk).not.toBeNull());
act(() => {
emitChunk?.({
kind: "progress",
progress: {
source: "providerNative",
kind: "message",
stage: "delta",
label: "Analyse",
text: "Lecture du contexte disponible.",
},
});
});
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
expect(screen.getByText("Progress").parentElement?.textContent).toContain("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("Progress").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 custom/), {
target: { value: "ship ordered stream" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(emitChunk).not.toBeNull());
act(() => {
emitChunk?.({ kind: "final", content: "done" });
});
await screen.findByText("done");
act(() => {
emitChunk?.({
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "started",
label: "idea_ticket_read",
toolName: "idea_ticket_read",
},
});
});
expect(screen.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 custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/c" } });
const menu = await screen.findByRole("listbox", {
name: "suggestions commandes slash",
});
expect(agent.listSlashCommands).toHaveBeenLastCalledWith("/c");
expect(screen.getByRole("option", { name: /\/clean/ })).toBeTruthy();
expect(screen.getByRole("option", { name: /\/commit/ })).toBeTruthy();
fireEvent.keyDown(composer, { key: "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 custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "hello /c" } });
expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull();
expect(agent.listSlashCommands).not.toHaveBeenCalled();
fireEvent.change(composer, { target: { value: "/" } });
await screen.findByRole("listbox", { name: "suggestions commandes slash" });
fireEvent.change(composer, { target: { value: "/clean now" } });
expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull();
});
it("dispatches plugin slash-command callback effects through the plugin runtime", async () => {
const commandHandler = vi.fn(async () => {});
const registry = new PluginRuntimeRegistry();
addPluginCommand(
registry,
"dev.acme.explain",
"dev.acme.explain.run",
commandHandler,
);
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
executeSlashCommand: vi.fn(async (_name: string, options) => ({
command: {
name: "/explain",
shortDescription: "Explain selection",
requiresConfirmation: false,
availability: { status: "available" as const },
source: { plugin: { pluginId: "dev.acme.explain" } },
plugin: {
pluginId: "dev.acme.explain",
commandId: "dev.acme.explain.run",
},
},
effect: {
kind: "pluginCallback" as const,
pluginId: "dev.acme.explain",
commandId: "dev.acme.explain.run",
sessionId: options.sessionId,
arguments: options.arguments ?? [],
},
})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<PluginRuntimeProvider
value={{ registry, failures: [], pending: [], loading: false }}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</PluginRuntimeProvider>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/explain focus this" } });
fireEvent.keyDown(composer, { key: "Enter" });
await waitFor(() =>
expect(agent.executeSlashCommand).toHaveBeenCalledWith("/explain", {
sessionId: "chat-session-1",
arguments: ["focus this"],
}),
);
await waitFor(() => expect(commandHandler).toHaveBeenCalledWith("focus this"));
expect(agent.sendAgentChat).not.toHaveBeenCalled();
expect(
await screen.findByText("Commande plugin /explain exécutée."),
).toBeTruthy();
expect(composer.value).toBe("");
});
it("executes /clean as a native conversation clear effect", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [{ kind: "userPrompt", text: "old conversation" }],
})),
sendAgentChat: vi.fn(async () => {}),
executeSlashCommand: vi.fn(async () => ({
command: {
name: "/clean",
shortDescription: "Nettoyer la conversation courante",
requiresConfirmation: false,
availability: { status: "available" as const },
source: "native" as const,
native: "clean" as const,
},
effect: {
kind: "cleanConversation" as const,
sessionId: "chat-session-1",
clearedChunks: 1,
},
})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
expect(await screen.findByText("old conversation")).toBeTruthy();
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/clean" } });
fireEvent.keyDown(composer, { key: "Enter" });
await waitFor(() =>
expect(agent.executeSlashCommand).toHaveBeenCalledWith("/clean", {
sessionId: "chat-session-1",
}),
);
await waitFor(() => expect(screen.queryByText("old conversation")).toBeNull());
expect(screen.queryByRole("alert")).toBeNull();
expect(agent.sendAgentChat).not.toHaveBeenCalled();
expect(composer.value).toBe("");
});
it("executes /help as a native help effect", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
executeSlashCommand: vi.fn(async () => ({
command: {
name: "/help",
shortDescription: "Afficher les commandes disponibles",
requiresConfirmation: false,
availability: { status: "available" as const },
source: "native" as const,
native: "help" as const,
},
effect: {
kind: "help" as const,
commands: [
{
name: "/help",
shortDescription: "Afficher les commandes disponibles",
requiresConfirmation: false,
availability: { status: "available" as const },
source: "native" as const,
native: "help" as const,
},
{
name: "/clean",
shortDescription: "Nettoyer la conversation courante",
requiresConfirmation: false,
availability: { status: "available" as const },
source: "native" as const,
native: "clean" as const,
},
{
name: "/profile",
shortDescription: "Changer le profil de l'agent",
requiresConfirmation: true,
availability: { status: "available" as const },
source: "native" as const,
native: "profile" as const,
},
],
},
})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/help" } });
fireEvent.keyDown(composer, { key: "Enter" });
await waitFor(() =>
expect(agent.executeSlashCommand).toHaveBeenCalledWith("/help", {
sessionId: "chat-session-1",
}),
);
expect(
await screen.findByText(/\/clean - Nettoyer la conversation courante/),
).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
expect(agent.sendAgentChat).not.toHaveBeenCalled();
expect(composer.value).toBe("");
});
it("shows feedback when a plugin slash-command callback cannot be dispatched", async () => {
const registry = new PluginRuntimeRegistry();
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
executeSlashCommand: vi.fn(async () => ({
command: {
name: "/explain",
shortDescription: "Explain selection",
requiresConfirmation: false,
availability: { status: "available" as const },
source: { plugin: { pluginId: "dev.acme.missing" } },
plugin: {
pluginId: "dev.acme.missing",
commandId: "dev.acme.missing.run",
},
},
effect: {
kind: "pluginCallback" as const,
pluginId: "dev.acme.missing",
commandId: "dev.acme.missing.run",
arguments: [],
},
})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<PluginRuntimeProvider
value={{ registry, failures: [], pending: [], loading: false }}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</PluginRuntimeProvider>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const composer = screen.getByLabelText(/message CLI custom/);
fireEvent.change(composer, { target: { value: "/explain" } });
fireEvent.keyDown(composer, { key: "Enter" });
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("Commande /explain échouée");
expect(alert.textContent).toContain('plugin "dev.acme.missing" is not loaded');
expect(screen.getAllByText(/Commande \/explain échouée/)).toHaveLength(2);
expect(agent.sendAgentChat).not.toHaveBeenCalled();
});
it("executes /profile as a confirmed profile-switch flow that resets the current session", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-2",
cellKind: "chat" as const,
assignedConversationId: "conversation-2",
})),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
executeSlashCommand: vi.fn(async () => ({
command: {
name: "/profile",
shortDescription: "Changer le profil de l'agent",
requiresConfirmation: true,
availability: { status: "available" as const },
source: "native" as const,
native: "profile" as const,
},
effect: {
kind: "profileSwitch" as const,
sessionId: "chat-session-1",
},
})),
changeAgentProfile: vi.fn(async () => ({ agent: { id: "agent-1" } })),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const profileGateway = {
listProfiles: vi.fn(async () => [
profile,
{
...profile,
id: "codex-high",
name: "Codex High",
},
]),
};
const onSessionId = vi.fn();
const onConversationId = vi.fn();
render(
<DIProvider
gateways={{
agent,
profile: profileGateway,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={onSessionId}
onConversationId={onConversationId}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/profile" } });
fireEvent.keyDown(composer, { key: "Enter" });
const dialog = await screen.findByRole("dialog", { name: "Changer de profil" });
expect(dialog.textContent).toContain("réinitialiser la session courante");
expect(dialog.textContent).toContain("L'historique de cette CLI custom sera perdu");
expect(agent.executeSlashCommand).toHaveBeenCalledWith("/profile", {
sessionId: "chat-session-1",
});
expect(agent.sendAgentChat).not.toHaveBeenCalled();
const confirmButton = screen.getByRole("button", {
name: "Reset et changer le profil",
}) as HTMLButtonElement;
expect(confirmButton.disabled).toBe(true);
fireEvent.change(screen.getByLabelText("profil cible"), {
target: { value: "codex-high" },
});
fireEvent.click(screen.getByLabelText("Je confirme que la session courante sera reset."));
expect(confirmButton.disabled).toBe(false);
fireEvent.click(confirmButton);
await waitFor(() =>
expect(agent.changeAgentProfile).toHaveBeenCalledWith(
"project-1",
"agent-1",
"codex-high",
24,
80,
),
);
await waitFor(() =>
expect(agent.launchAgentChat).toHaveBeenCalledWith("project-1", "agent-1", {
cwd: "/repo",
rows: 24,
cols: 80,
conversationId: undefined,
nodeId: "node-1",
}),
);
expect(onSessionId).toHaveBeenCalledWith(null);
expect(onConversationId).toHaveBeenCalledWith(null);
expect(onSessionId).toHaveBeenCalledWith("chat-session-2");
expect(onConversationId).toHaveBeenCalledWith("conversation-2");
expect(screen.getByText("Profil changé vers Codex High.")).toBeTruthy();
expect(screen.queryByRole("dialog", { name: "Changer de profil" })).toBeNull();
});
it("pastes a clipboard image as a removable preview chip", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const file = new File(["ignored"], "clipboard.png", { type: "image/png" });
Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
});
const composer = screen.getByLabelText(/message CLI custom/) 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 custom/), {
clipboardData: {
items: [],
files: [],
getData: () => "",
},
});
await screen.findByText(/Fichier joint: clipboard-image-\d+\.png/);
expect(read).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1",
"[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 custom/), {
clipboardData: {
items: [],
files: [],
getData: () => "",
},
});
expect((await screen.findByRole("alert")).textContent).toContain(
"clipboard denied",
);
expect(
(screen.getByRole("button", { name: "Envoyer" }) as HTMLButtonElement)
.disabled,
).toBe(true);
} finally {
if (originalClipboard) {
Object.defineProperty(window.navigator, "clipboard", originalClipboard);
} else {
Object.defineProperty(window.navigator, "clipboard", {
configurable: true,
value: undefined,
});
}
}
});
it("sends a pasted clipboard image without requiring text", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const file = new File(["ignored"], "paste-image.png", { type: "image/png" });
Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
});
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
clipboardData: {
items: [
{
kind: "file",
type: "image/png",
getAsFile: () => file,
},
],
getData: () => "",
},
});
await screen.findByText("Fichier joint: paste-image.png");
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1",
"[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 custom/), {
clipboardData: {
items: [],
files: [file],
getData: () => "",
},
});
await screen.findByText("Fichier joint: files-only.pdf");
expect(screen.queryByTestId("attachment-preview-files-only.pdf")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1",
"",
expect.any(Function),
{
attachments: [
{
filename: "files-only.pdf",
contentBase64: "BAUG",
mime: "application/pdf",
sourceKind: "clipboard",
},
],
},
);
});
it("keeps pasted text insertion when clipboard files are attached", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "prefix suffix" } });
composer.setSelectionRange(7, 13);
const file = new File(["ignored"], "mixed.txt", { type: "text/plain" });
Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([7, 8, 9]).buffer),
});
fireEvent.paste(composer, {
clipboardData: {
items: [
{
kind: "file",
type: "text/plain",
getAsFile: () => file,
},
],
getData: () => "inserted",
},
});
await screen.findByText("Fichier joint: mixed.txt");
await waitFor(() => expect(composer.value).toBe("prefix inserted"));
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1",
"prefix inserted",
expect.any(Function),
{
attachments: [
{
filename: "mixed.txt",
contentBase64: "BwgJ",
mime: "text/plain",
sourceKind: "clipboard",
},
],
},
);
expect(screen.getByText("prefix inserted")).toBeTruthy();
expect(screen.getByText("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 custom/) 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 custom/);
fireEvent.paste(composer, { clipboardData });
await screen.findByText("Fichier joint: duplicate.pdf");
fireEvent.paste(composer, { clipboardData });
await waitFor(() =>
expect(screen.getAllByText("Fichier joint: duplicate.pdf")).toHaveLength(1),
);
});
it("restores and saves drafts scoped by project, layout, and node", async () => {
const drafts = new Map<string, string>([
["project-1::layout-a::node-1", "restored draft"],
]);
const key = (scope: ChatDraftScope) =>
`${scope.projectId}::${scope.layoutId}::${scope.nodeId}`;
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
readChatDraft: vi.fn(async (scope: ChatDraftScope) => ({
text: drafts.get(key(scope)) ?? null,
updatedAtMs: null,
})),
saveChatDraft: vi.fn(async (scope: ChatDraftScope, text: string) => {
if (text) drafts.set(key(scope), text);
else drafts.delete(key(scope));
}),
clearChatDraft: vi.fn(async (scope: ChatDraftScope) => {
drafts.delete(key(scope));
}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const baseProps = {
agentId: "agent-1",
agentName: "Worker",
profile,
cwd: "/repo",
nodeId: "node-1",
sessionId: "chat-session-1",
conversationId: "conversation-1",
onSessionId: vi.fn(),
onConversationId: vi.fn(),
};
const { rerender } = render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
{...baseProps}
projectId="project-1"
layoutId="layout-a"
/>
</DIProvider>,
);
const composer = await screen.findByDisplayValue("restored draft");
fireEvent.change(composer, { target: { value: "edited draft" } });
await waitFor(() =>
expect(agent.saveChatDraft).toHaveBeenCalledWith(
{ projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" },
"edited draft",
),
);
rerender(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
{...baseProps}
projectId="project-1"
layoutId="layout-b"
/>
</DIProvider>,
);
await waitFor(() =>
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""),
);
rerender(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
{...baseProps}
projectId="project-1"
layoutId="layout-a"
/>
</DIProvider>,
);
await screen.findByDisplayValue("edited draft");
rerender(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
{...baseProps}
projectId="project-2"
layoutId="layout-a"
/>
</DIProvider>,
);
await waitFor(() =>
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""),
);
});
it("does not persist attachments with the restored draft", async () => {
const drafts = new Map<string, string>();
const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" };
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
readChatDraft: vi.fn(async () => ({
text: drafts.get("draft") ?? null,
updatedAtMs: null,
})),
saveChatDraft: vi.fn(async (_scope: ChatDraftScope, text: string) => {
drafts.set("draft", text);
}),
clearChatDraft: vi.fn(async () => {
drafts.delete("draft");
}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const renderView = () =>
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId={scope.projectId}
layoutId={scope.layoutId}
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId={scope.nodeId}
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
const first = renderView();
await waitFor(() =>
expect(agent.readChatDraft).toHaveBeenCalledWith(scope),
);
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "draft with file" } });
const file = new File(["ignored"], "not-persisted.png", { type: "image/png" });
Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
});
fireEvent.paste(composer, {
clipboardData: {
items: [
{
kind: "file",
type: "image/png",
getAsFile: () => file,
},
],
getData: () => "",
},
});
await screen.findByText("Fichier joint: not-persisted.png");
await waitFor(() =>
expect(agent.saveChatDraft).toHaveBeenCalledWith(scope, "draft with file[Image 1]"),
);
first.unmount();
renderView();
await screen.findByDisplayValue("draft with file[Image 1]");
expect(screen.queryByText("Fichier joint: not-persisted.png")).toBeNull();
});
it("clears the persisted draft when sending the composer", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
readChatDraft: vi.fn(async () => ({ text: "to send", updatedAtMs: null })),
saveChatDraft: vi.fn(async () => {}),
clearChatDraft: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" };
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId={scope.projectId}
layoutId={scope.layoutId}
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId={scope.nodeId}
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await screen.findByDisplayValue("to send");
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
expect(agent.clearChatDraft).toHaveBeenCalledWith(scope);
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe("");
});
it("clears the persisted draft when a slash command is consumed", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
executeSlashCommand: vi.fn(async () => ({
command: {
name: "/help",
shortDescription: "Afficher les commandes disponibles",
requiresConfirmation: false,
availability: { status: "available" as const },
source: "native" as const,
native: "help" as const,
},
effect: {
kind: "help" as const,
commands: [],
},
})),
readChatDraft: vi.fn(async () => ({ text: "/help", updatedAtMs: null })),
saveChatDraft: vi.fn(async () => {}),
clearChatDraft: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" };
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId={scope.projectId}
layoutId={scope.layoutId}
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId={scope.nodeId}
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await screen.findByDisplayValue("/help");
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() =>
expect(agent.executeSlashCommand).toHaveBeenCalledWith("/help", {
sessionId: "chat-session-1",
}),
);
expect(agent.clearChatDraft).toHaveBeenCalledWith(scope);
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe("");
});
it("keeps the chat shell bounded with a scrollable message area and fixed composer", async () => {
const agent = {
launchAgentChat: vi.fn(() => new Promise<never>(() => {})),
reattachAgentChat: vi.fn(),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker with a very long name that must not collide with controls"
profile={{
...profile,
name: "Structured profile with a very long display name",
}}
cwd="/repo"
nodeId="node-1"
sessionId={null}
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
const shell = screen.getByTestId("custom-agent-chat-view");
const toolbar = await screen.findByRole("toolbar", {
name: "custom agent chat actions",
});
const scroll = screen.getByTestId("custom-agent-chat-scroll");
const composer = screen.getByTestId("custom-agent-chat-composer");
expect(shell.className).toContain("h-full");
expect(shell.className).toContain("min-h-0");
expect(shell.className).toContain("overflow-hidden");
expect(toolbar.className).toContain("overflow-hidden");
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
expect(scroll.className).toContain("flex-1");
expect(scroll.className).toContain("basis-0");
expect(scroll.className).toContain("overflow-y-auto");
expect(composer.className).toContain("shrink-0");
});
it("falls back when Tauri surfaces structured session NOT_FOUND as a raw string", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-2",
assignedConversationId: "conversation-2",
})),
reattachAgentChat: vi
.fn()
.mockRejectedValueOnce(
"not found: structured session 69b57638-0ac7-4a79-8252-e0436a3265f6",
)
.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 custom/), {
target: { value: "please recover" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2));
expect(agent.sendAgentChat).toHaveBeenNthCalledWith(
1,
"stale-session",
"please recover",
expect.any(Function),
);
expect(agent.sendAgentChat).toHaveBeenNthCalledWith(
2,
"chat-session-2",
"please recover",
expect.any(Function),
);
expect(onSessionId).toHaveBeenCalledWith(null);
expect(onSessionId).toHaveBeenCalledWith("chat-session-2");
expect(await screen.findByText("Recovered reply")).toBeTruthy();
expect(screen.queryByText(/not found: structured session/)).toBeNull();
});
it("recovers and retries the prompt when send reports structured session NOT_FOUND as a raw string", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-2",
assignedConversationId: "conversation-2",
})),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi
.fn()
.mockRejectedValueOnce("not found: structured session stale-session")
.mockImplementationOnce(async (_sessionId: string, _prompt: string, onChunk) => {
onChunk({ kind: "final", content: "Recovered reply" });
}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="stale-session"
conversationId="conversation-1"
onSessionId={onSessionId}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"stale-session",
expect.any(Function),
),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "please recover" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2));
expect(agent.sendAgentChat).toHaveBeenNthCalledWith(
2,
"chat-session-2",
"please recover",
expect.any(Function),
);
expect(onSessionId).toHaveBeenCalledWith(null);
expect(onSessionId).toHaveBeenCalledWith("chat-session-2");
expect(await screen.findByText("Recovered reply")).toBeTruthy();
expect(screen.queryByText(/not found: structured session/)).toBeNull();
});
it("treats NOT_FOUND during 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 custom/), {
target: { value: "long turn" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalled());
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
expect(await screen.findByText("Request already stopped.")).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();
});
});