feat(chat): valide le routing cellKind=chat côté frontend et corrige UX

- Ajoute cellKind dans AgentChatHandle et vérifie que launchAgentChat reçoit cellKind:chat
- Rejette avec erreur STRUCTURED_ROUTED_TO_PTY si le backend route vers PTY
- Ajoute cellKind:chat dans MockAgentGateway.launchAgentChat
- Corrige l'overflow du layout CustomAgentChatView (bounded shell avec scroll + composer fixe)
- Dédouble les prompts utilisateur quand le stream les echo
- Ajoute les tests launchAgentChat returns handle only when confirms chat et launchAgentChat rejects PTY-routed launch
- Ajoute les tests does not duplicate user prompt when echoed et keeps chat shell bounded

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 23:39:50 +02:00
parent c43f29b2f2
commit 937ea07df1
6 changed files with 322 additions and 22 deletions

View File

@ -152,6 +152,112 @@ describe("CustomAgentChatView", () => {
expect(screen.queryByText(/structured session gone/)).toBeNull();
});
it("does not duplicate the user prompt when the stream echoes it back", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async (_sessionId: string, _prompt: string, onChunk) => {
onChunk({ kind: "userPrompt", text: "hello agent" });
onChunk({ kind: "final", content: "done" });
}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "hello agent" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await screen.findByText("done");
expect(screen.getAllByText("hello agent")).toHaveLength(1);
});
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");
const cancel = await screen.findByRole("button", { name: "Cancel" });
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(cancel.className).toContain("shrink-0");
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 () => ({
@ -476,6 +582,7 @@ describe("CustomAgentChatView", () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "new-session",
cellKind: "chat",
assignedConversationId: "conversation-2",
})),
reattachAgentChat: vi.fn().mockRejectedValueOnce({
@ -514,6 +621,49 @@ describe("CustomAgentChatView", () => {
);
});
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

View File

@ -93,6 +93,12 @@ function appendAgentDelta(turns: ChatTurn[], text: string): ChatTurn[] {
return next;
}
function appendUserPrompt(turns: ChatTurn[], text: string): ChatTurn[] {
const last = turns[turns.length - 1];
if (last?.role === "user" && last.text === text) return turns;
return [...turns, { role: "user", text }];
}
function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
if (!isReplyRecord(raw)) {
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
@ -119,7 +125,7 @@ function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
}
case "userPrompt":
case "UserPrompt":
return [...turns, { role: "user", text: String(raw.text ?? raw.prompt ?? "") }];
return appendUserPrompt(turns, String(raw.text ?? raw.prompt ?? ""));
default:
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
}
@ -481,20 +487,31 @@ export function CustomAgentChatView({
return (
<div
data-testid="custom-agent-chat-view"
className="flex h-full min-h-0 flex-col bg-surface text-content"
className="flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content"
>
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
<div className="min-w-0">
<div
role="toolbar"
aria-label="custom agent chat actions"
className="flex shrink-0 items-center gap-2 overflow-hidden border-b border-border px-3 py-2"
>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="truncate text-sm font-medium">{agentName}</div>
<div className="truncate text-xs text-muted">
CLI custom · {profile.name}
</div>
</div>
{(opening || busy) && (
<Button size="sm" variant="danger" onClick={() => void cancel()}>
Cancel
</Button>
)}
<div className="flex shrink-0 items-center justify-end gap-2">
{(opening || busy) && (
<Button
size="sm"
variant="danger"
className="shrink-0 whitespace-nowrap"
onClick={() => void cancel()}
>
Cancel
</Button>
)}
</div>
</div>
{!supported && (
@ -508,7 +525,11 @@ export function CustomAgentChatView({
</p>
)}
<div ref={scrollRef} className="flex min-h-0 flex-1 flex-col gap-2 overflow-auto px-3 py-3">
<div
ref={scrollRef}
data-testid="custom-agent-chat-scroll"
className="flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
>
{opening && turns.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
@ -523,7 +544,10 @@ export function CustomAgentChatView({
)}
</div>
<div className="flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2">
<div
data-testid="custom-agent-chat-composer"
className="flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2"
>
{attachment && (
<div className="flex items-center justify-between gap-2 rounded-md border border-border bg-surface px-2 py-1 text-xs text-muted">
<span className="truncate">Fichier joint: {attachment}</span>
@ -532,7 +556,7 @@ export function CustomAgentChatView({
</button>
</div>
)}
<div className="flex items-end gap-2">
<div className="flex min-w-0 items-end gap-2">
<textarea
aria-label={`message CLI custom ${nodeId}`}
className={cn(
@ -551,10 +575,22 @@ export function CustomAgentChatView({
}
}}
/>
<Button size="sm" variant="ghost" disabled={!supported || opening || busy} onClick={() => void pickAttachment()}>
<Button
size="sm"
variant="ghost"
className="shrink-0 whitespace-nowrap"
disabled={!supported || opening || busy}
onClick={() => void pickAttachment()}
>
Joindre
</Button>
<Button size="sm" disabled={!canSend} loading={busy} onClick={() => void send()}>
<Button
size="sm"
className="shrink-0 whitespace-nowrap"
disabled={!canSend}
loading={busy}
onClick={() => void send()}
>
Envoyer
</Button>
</div>
@ -566,8 +602,8 @@ export function CustomAgentChatView({
function ChatBubble({ turn }: { turn: ChatTurn }) {
if (turn.role === "tool") {
return (
<div className="flex justify-center">
<span className="max-w-[80%] rounded-md border border-border bg-raised px-2 py-1 text-xs text-muted">
<div className="flex min-w-0 justify-center">
<span className="max-w-[80%] truncate rounded-md border border-border bg-raised px-2 py-1 text-xs text-muted">
{turn.label}
</span>
</div>
@ -575,8 +611,8 @@ function ChatBubble({ turn }: { turn: ChatTurn }) {
}
if (turn.role === "final") {
return (
<div className="flex justify-end">
<div className="max-w-[86%] rounded-md border border-success/50 bg-success/10 px-3 py-2 text-sm text-content">
<div className="flex min-w-0 justify-end">
<div className="min-w-0 max-w-[86%] overflow-hidden rounded-md border border-success/50 bg-success/10 px-3 py-2 text-sm text-content">
<div className="mb-1 text-xs font-semibold text-success">Task Complete</div>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
</div>
@ -585,8 +621,8 @@ function ChatBubble({ turn }: { turn: ChatTurn }) {
}
if (turn.role === "error" || turn.role === "unknown") {
return (
<div className="flex justify-center">
<div className="max-w-[86%] rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger">
<div className="flex min-w-0 justify-center">
<div className="min-w-0 max-w-[86%] overflow-hidden rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger">
{turn.role === "unknown" ? "Chunk inconnu reçu: " : ""}
<span className="whitespace-pre-wrap break-words">{turn.text}</span>
</div>
@ -596,10 +632,10 @@ function ChatBubble({ turn }: { turn: ChatTurn }) {
const user = turn.role === "user";
return (
<div className={cn("flex", user ? "justify-start" : "justify-end")}>
<div className={cn("flex min-w-0", user ? "justify-start" : "justify-end")}>
<div
className={cn(
"max-w-[82%] rounded-lg border px-3 py-2 text-sm text-content",
"min-w-0 max-w-[82%] overflow-hidden rounded-lg border px-3 py-2 text-sm text-content",
user ? "border-border bg-raised" : "border-primary/25 bg-primary/10",
)}
>