diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 77e42f8..6ee04ff 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -151,6 +151,250 @@ describe("CustomAgentChatView", () => { expect(screen.queryByText(/structured session gone/)).toBeNull(); }); + 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( + null) }, + } as unknown as Gateways} + > + + , + ); + + 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("treats NOT_FOUND during cancel as an already-gone session", async () => { + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(() => new Promise(() => {})), + cancelAgentChat: vi.fn().mockRejectedValueOnce({ + code: "NOT_FOUND", + message: "not found: structured session gone-session", + }), + closeAgentChat: vi.fn(async () => {}), + }; + const onSessionId = vi.fn(); + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + 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: "Cancel" })); + + expect(await screen.findByText("Session déjà fermée.")).toBeTruthy(); + expect(onSessionId).toHaveBeenCalledWith(null); + 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", + assignedConversationId: "conversation-2", + })), + reattachAgentChat: vi.fn().mockRejectedValueOnce({ + code: "PROCESS", + message: "post-launch attach failed", + }), + sendAgentChat: vi.fn(() => new Promise(() => {})), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + expect((await screen.findByRole("alert")).textContent).toContain( + "post-launch attach failed", + ); + }); + + 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(() => {})), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + const onSessionId = vi.fn(); + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + 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(), diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index ad8e60e..bb65809 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -6,7 +6,7 @@ * deliberately does not try to parse PTY bytes. */ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AgentProfile, GatewayError, ReplyChunk } from "@/domain"; import { useGateways } from "@/app/di"; @@ -40,6 +40,10 @@ function describe(e: unknown): string { return String(e); } +function isNotFound(e: unknown): boolean { + return Boolean(e && typeof e === "object" && (e as GatewayError).code === "NOT_FOUND"); +} + function unknownChunkLabel(chunk: unknown): string { try { return JSON.stringify(chunk); @@ -122,6 +126,7 @@ export function CustomAgentChatView({ const scrollRef = useRef(null); const sessionRef = useRef(sessionId); sessionRef.current = currentSession; + const mountedRef = useRef(false); const onSessionIdRef = useRef(onSessionId); onSessionIdRef.current = onSessionId; const onConversationIdRef = useRef(onConversationId); @@ -136,6 +141,78 @@ export function CustomAgentChatView({ agent.closeAgentChat, ); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const receive = useCallback((chunk: ReplyChunk) => { + setTurns((prev) => foldChunk(prev, chunk)); + if (chunk.kind === "final" || chunk.kind === "error") setBusy(false); + }, []); + + const reattachStructuredSession = useCallback( + async (sid: string, options: { applyScrollback?: boolean } = {}) => { + if (!agent.reattachAgentChat) throw new Error("Structured reattach unavailable"); + const reattached = await agent.reattachAgentChat(sid, receive); + if (!mountedRef.current) return reattached.sessionId; + setCurrentSession(reattached.sessionId); + if (options.applyScrollback) { + setTurns(reattached.scrollback.reduce(foldChunk, [] as ChatTurn[])); + } + return reattached.sessionId; + }, + [agent, receive], + ); + + const recoverStructuredSession = useCallback( + async (options: { applyScrollback?: boolean; retryAttachNotFound?: boolean } = {}) => { + if (!agent.launchAgentChat) throw new Error("Structured launch unavailable"); + let retryAttachNotFound = Boolean(options.retryAttachNotFound); + for (;;) { + const launched = await agent.launchAgentChat(projectId, agentId, { + cwd, + rows: 24, + cols: 80, + conversationId: conversationId ?? undefined, + nodeId, + }); + if (!mountedRef.current) return launched.sessionId; + setCurrentSession(launched.sessionId); + onSessionIdRef.current(launched.sessionId); + if (launched.assignedConversationId) { + onConversationIdRef.current(launched.assignedConversationId); + } + try { + return await reattachStructuredSession(launched.sessionId, { + applyScrollback: options.applyScrollback, + }); + } catch (e) { + if (isNotFound(e) && retryAttachNotFound) { + retryAttachNotFound = false; + if (mountedRef.current) { + setCurrentSession(null); + onSessionIdRef.current(null); + } + continue; + } + throw e; + } + } + }, + [ + agent, + projectId, + agentId, + cwd, + nodeId, + conversationId, + reattachStructuredSession, + ], + ); + useEffect(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; @@ -144,10 +221,6 @@ export function CustomAgentChatView({ useEffect(() => { if (!supported) return; let cancelled = false; - const receive = (chunk: ReplyChunk) => { - setTurns((prev) => foldChunk(prev, chunk)); - if (chunk.kind === "final" || chunk.kind === "error") setBusy(false); - }; async function openOrAttach() { setOpening(true); @@ -155,33 +228,25 @@ export function CustomAgentChatView({ try { if (sessionId) { try { - const reattached = await agent.reattachAgentChat!(sessionId, receive); + await reattachStructuredSession(sessionId, { applyScrollback: true }); if (cancelled) return; - setCurrentSession(reattached.sessionId); - setTurns(reattached.scrollback.reduce(foldChunk, [] as ChatTurn[])); return; } catch (e) { // The backend contract for `reattach_agent_chat` (NOT_FOUND) is that // the session is gone (closed/never live) and the caller falls back // to a fresh launch — any other error still surfaces to the user. - if ((e as GatewayError)?.code !== "NOT_FOUND") throw e; + if (!isNotFound(e)) throw e; + if (!cancelled) { + setCurrentSession(null); + onSessionIdRef.current(null); + } } } - const launched = await agent.launchAgentChat!(projectId, agentId, { - cwd, - rows: 24, - cols: 80, - conversationId: conversationId ?? undefined, - nodeId, + await recoverStructuredSession({ + applyScrollback: false, + retryAttachNotFound: true, }); if (cancelled) return; - setCurrentSession(launched.sessionId); - onSessionIdRef.current(launched.sessionId); - if (launched.assignedConversationId) { - onConversationIdRef.current(launched.assignedConversationId); - } - // Attach the view so any in-flight chunks can be replayed after launch. - await agent.reattachAgentChat!(launched.sessionId, receive).catch(() => {}); } catch (e) { if (!cancelled) setError(describe(e)); } finally { @@ -195,23 +260,18 @@ export function CustomAgentChatView({ }; }, [ supported, - agent, - projectId, - agentId, - cwd, - nodeId, sessionId, - conversationId, + reattachStructuredSession, + recoverStructuredSession, ]); const canSend = useMemo( () => supported && - Boolean(currentSession) && Boolean(draft.trim()) && !busy && !opening, - [supported, currentSession, draft, busy, opening], + [supported, draft, busy, opening], ); async function pickAttachment() { @@ -221,7 +281,7 @@ export function CustomAgentChatView({ async function send() { const text = draft.trim(); - if (!canSend || !currentSession || !agent.sendAgentChat) return; + if (!canSend || !agent.sendAgentChat) return; const prompt = attachment ? `${text}\n\n[Fichier joint: ${attachment}]` : text; setDraft(""); setAttachment(null); @@ -229,11 +289,31 @@ export function CustomAgentChatView({ setError(null); setTurns((prev) => [...prev, { role: "user", text, attachment: attachment ?? undefined }]); try { - await agent.sendAgentChat(currentSession, prompt, (chunk) => { - setTurns((prev) => foldChunk(prev, chunk)); - if (chunk.kind === "final" || chunk.kind === "error") setBusy(false); - }); + const sid = + currentSession ?? + (await recoverStructuredSession({ + applyScrollback: false, + retryAttachNotFound: true, + })); + await agent.sendAgentChat(sid, prompt, receive); } catch (e) { + if (isNotFound(e)) { + try { + setCurrentSession(null); + onSessionIdRef.current(null); + const recoveredSession = await recoverStructuredSession({ + applyScrollback: false, + retryAttachNotFound: true, + }); + await agent.sendAgentChat(recoveredSession, prompt, receive); + return; + } catch (recoveryError) { + setBusy(false); + setError(describe(recoveryError)); + setTurns((prev) => [...prev, { role: "error", text: describe(recoveryError) }]); + return; + } + } setBusy(false); setError(describe(e)); setTurns((prev) => [...prev, { role: "error", text: describe(e) }]); @@ -250,6 +330,12 @@ export function CustomAgentChatView({ await agent.cancelAgentChat(sid); setTurns((prev) => [...prev, { role: "tool", label: "Tour interrompu." }]); } catch (e) { + if (isNotFound(e)) { + setCurrentSession(null); + onSessionIdRef.current(null); + setTurns((prev) => [...prev, { role: "tool", label: "Session déjà fermée." }]); + return; + } setError(describe(e)); } }