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));
}
}