From 937ea07df1f4824a69fd5dfdc5b099f5b26ac3e4 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 5 Aug 2026 23:39:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(chat):=20valide=20le=20routing=20cellKind?= =?UTF-8?q?=3Dchat=20c=C3=B4t=C3=A9=20frontend=20et=20corrige=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/adapters/agent.test.ts | 73 +++++++++ frontend/src/adapters/agent.ts | 38 +++++ frontend/src/adapters/mock/index.ts | 1 + .../agents/CustomAgentChatView.test.tsx | 150 ++++++++++++++++++ .../features/agents/CustomAgentChatView.tsx | 80 +++++++--- frontend/src/ports/index.ts | 2 + 6 files changed, 322 insertions(+), 22 deletions(-) diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index 79c976d..d1ecfb0 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -170,4 +170,77 @@ describe("TauriAgentGateway invoke payloads", () => { }); expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything()); }); + + it("launchAgentChat returns a handle only when launch_agent confirms cellKind chat", async () => { + invoke.mockResolvedValueOnce({ + sessionId: "chat-session-1", + cwd: "/repo", + rows: 24, + cols: 80, + cellKind: "chat", + assignedConversationId: "conversation-1", + }); + + const out = await new TauriAgentGateway().launchAgentChat("proj-1", "agent-2", { + cwd: "/repo", + rows: 24, + cols: 80, + conversationId: "conversation-0", + nodeId: "node-3", + }); + + expect(invoke).toHaveBeenCalledWith("launch_agent", { + request: { + projectId: "proj-1", + agentId: "agent-2", + rows: 24, + cols: 80, + cellKind: "chat", + conversationId: "conversation-0", + nodeId: "node-3", + }, + onOutput: expect.anything(), + }); + expect(out).toEqual({ + sessionId: "chat-session-1", + cellKind: "chat", + assignedConversationId: "conversation-1", + }); + }); + + it("launchAgentChat rejects a PTY-routed launch before returning a fake structured session", async () => { + const log = vi.spyOn(console, "error").mockImplementation(() => {}); + invoke.mockResolvedValueOnce({ + sessionId: "pty-session-1", + cwd: "/repo", + rows: 24, + cols: 80, + cellKind: "pty", + }); + + await expect( + new TauriAgentGateway().launchAgentChat("proj-1", "agent-2", { + cwd: "/repo", + rows: 24, + cols: 80, + nodeId: "node-3", + }), + ).rejects.toEqual({ + code: "STRUCTURED_ROUTED_TO_PTY", + message: + "custom CLI launch for agent agent-2 in project proj-1 returned cellKind=pty; expected chat. sessionId=pty-session-1; nodeId=node-3", + }); + expect(log).toHaveBeenCalledWith( + "[ticket149] launchAgentChat:routed-to-non-chat", + expect.objectContaining({ + projectId: "proj-1", + agentId: "agent-2", + response: expect.objectContaining({ + sessionId: "pty-session-1", + cellKind: "pty", + }), + }), + ); + log.mockRestore(); + }); }); diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index a8666bb..361d809 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -18,6 +18,7 @@ import type { Agent, AgentContextDocument, EffortSelection, + GatewayError, ReplyChunk, ResumableAgent, TerminalSession, @@ -42,10 +43,28 @@ interface LaunchAgentResponse { cwd: string; rows: number; cols: number; + /** Backend-derived routing: `chat` for structured sessions, `pty` for native TUI. */ + cellKind?: "chat" | "pty"; /** Conversation id minted by this launch (omitted when nothing was assigned). */ assignedConversationId?: string; } +function structuredLaunchRoutedToPtyError( + projectId: string, + agentId: string, + options: OpenTerminalOptions, + response: LaunchAgentResponse, +): GatewayError { + const actual = response.cellKind ?? "missing"; + return { + code: "STRUCTURED_ROUTED_TO_PTY", + message: + `custom CLI launch for agent ${agentId} in project ${projectId} ` + + `returned cellKind=${actual}; expected chat. ` + + `sessionId=${response.sessionId}; nodeId=${options.nodeId ?? "none"}`, + }; +} + export class TauriAgentGateway implements AgentGateway { listAgents(projectId: string): Promise { return invoke("list_agents", { projectId }); @@ -194,13 +213,32 @@ export class TauriAgentGateway implements AgentGateway { agentId, rows: options.rows, cols: options.cols, + cellKind: "chat", conversationId: options.conversationId ?? null, nodeId: options.nodeId ?? null, }, onOutput: channel, }); + if (res.cellKind !== "chat") { + const error = structuredLaunchRoutedToPtyError(projectId, agentId, options, res); + console.error("[ticket149] launchAgentChat:routed-to-non-chat", { + projectId, + agentId, + request: { + rows: options.rows, + cols: options.cols, + cellKind: "chat", + conversationId: options.conversationId ?? null, + nodeId: options.nodeId ?? null, + }, + response: res, + error, + }); + throw error; + } return { sessionId: res.sessionId, + cellKind: "chat", ...(res.assignedConversationId ? { assignedConversationId: res.assignedConversationId } : {}), diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index b8554ff..d3d8473 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -801,6 +801,7 @@ export class MockAgentGateway implements AgentGateway { } return { sessionId, + cellKind: "chat", ...(!options.conversationId ? { assignedConversationId: `mock-conversation-${sessionId}` } : {}), diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index a02e706..76263b3 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -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( + null) }, + } as unknown as Gateways} + > + + , + ); + + 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(() => {})), + reattachAgentChat: vi.fn(), + sendAgentChat: vi.fn(() => new Promise(() => {})), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + 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(() => {})), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + const onSessionId = vi.fn(); + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + 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 diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index ab2ef8b..73aa676 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -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 (
-
-
+
+
{agentName}
CLI custom · {profile.name}
- {(opening || busy) && ( - - )} +
+ {(opening || busy) && ( + + )} +
{!supported && ( @@ -508,7 +525,11 @@ export function CustomAgentChatView({

)} -
+
{opening && turns.length === 0 ? (
@@ -523,7 +544,10 @@ export function CustomAgentChatView({ )}
-
+
{attachment && (
Fichier joint: {attachment} @@ -532,7 +556,7 @@ export function CustomAgentChatView({
)} -
+