diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index a80d5f7f..cf55fd86 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -288,30 +288,21 @@ describe("CustomAgentChatView", () => { expect(screen.getByTestId("custom-agent-chat-composer").className).toContain("bg-raised/40"); }); - it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => { + it("restores a durable conversation without starting or sending to the provider (#366)", async () => { + const page = deferred<{ + turns: Array<{ id: string; atMs: number; role: "prompt" | "response"; source: { kind: "human" } | { kind: "agent"; agentId: string }; text: string; textLen: number }>; + hasMore: boolean; + }>(); const agent = { - launchAgentChat: vi.fn(async () => ({ - sessionId: "fresh-session", - transcriptUnavailableReason: "missingTranscript" as const, - })), - reattachAgentChat: vi.fn(async (sessionId: string) => ({ - sessionId, - scrollback: [], - transcriptUnavailableReason: "missingTranscript" as const, - })), + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; - - const onConversationId = vi.fn(); + const conversation = { readPage: vi.fn(() => page.promise) }; render( - null) }, - } as unknown as Gateways} - > + null) } } as unknown as Gateways}> { cwd="/repo" nodeId="node-1" sessionId={null} - conversationId="orphan-conversation" + conversationId="saved-conversation" onSessionId={vi.fn()} - onConversationId={onConversationId} + onConversationId={vi.fn()} /> , ); - 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("Sa transcription 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(); + expect(await screen.findByText("Chargement de la conversation…")).toBeTruthy(); + expect(screen.queryByText("Nouvelle conversation")).toBeNull(); + page.resolve({ + turns: [ + { id: "p1", atMs: 10, role: "prompt", source: { kind: "human" }, text: "Question sauvegardée", textLen: 20 }, + { id: "r1", atMs: 20, role: "response", source: { kind: "agent", agentId: "agent-1" }, text: "Réponse sauvegardée", textLen: 19 }, + ], + hasMore: false, + }); + expect(await screen.findByText("Question sauvegardée")).toBeTruthy(); + expect(screen.getByText("Réponse sauvegardée")).toBeTruthy(); + expect(agent.launchAgentChat).not.toHaveBeenCalled(); + expect(agent.reattachAgentChat).not.toHaveBeenCalled(); + expect(agent.sendAgentChat).not.toHaveBeenCalled(); }); - it("keeps an empty linked custom CLI continuation usable without explicit transcript loss", async () => { + it("keeps a durable load failure distinct and retries without clearing its conversation id", async () => { const agent = { - launchAgentChat: vi.fn(async () => ({ sessionId: "valid-session" })), - reattachAgentChat: vi.fn(async (sessionId: string) => ({ - sessionId, - scrollback: [], - })), - sendAgentChat: vi.fn(async () => {}), - cancelAgentChat: vi.fn(async () => {}), - closeAgentChat: vi.fn(async () => {}), - }; - render( - null) } } as unknown as Gateways}> - - , - ); - - 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("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( - null) } } as unknown as Gateways}> - - , - ); - 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" }; }), + launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; + const conversation = { + readPage: vi.fn() + .mockRejectedValueOnce(new Error("disk temporarily unavailable")) + .mockResolvedValueOnce({ + turns: [{ id: "p1", atMs: 10, role: "prompt" as const, source: { kind: "human" as const }, text: "Message restauré", textLen: 16 }], + hasMore: false, + }), + }; + const onConversationId = vi.fn(); render( - null) } } as unknown as Gateways}> - + null) } } as unknown as Gateways}> + , ); - expect(await screen.findByText("Cette conversation est introuvable dans le stockage disponible.")).toBeTruthy(); - expect(agent.launchAgentChat).toHaveBeenCalledTimes(1); + + expect(await screen.findByText(/Impossible de charger cette conversation sauvegardée/)).toBeTruthy(); + expect(onConversationId).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Réessayer" })); + expect(await screen.findByText("Message restauré")).toBeTruthy(); + expect(conversation.readPage).toHaveBeenCalledTimes(2); + expect(onConversationId).not.toHaveBeenCalled(); + expect(agent.launchAgentChat).not.toHaveBeenCalled(); + }); + + it("keeps a confirmed missing durable conversation selected until an explicit action", async () => { + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + const conversation = { readPage: vi.fn(async () => { throw { code: "NOT_FOUND", message: "conversation not found" }; }) }; + const onConversationId = vi.fn(); + render( + null) } } as unknown as Gateways}> + + , + ); + expect(await screen.findByText(/Cette conversation sauvegardée est introuvable/)).toBeTruthy(); expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Nouvelle conversation" })).toBeTruthy(); + expect(onConversationId).not.toHaveBeenCalled(); + expect(agent.launchAgentChat).not.toHaveBeenCalled(); }); it("projects self-restart lifecycle separators in the custom CLI only once", async () => { @@ -430,6 +405,7 @@ describe("CustomAgentChatView", () => { { } as unknown as Gateways} > { } as unknown as Gateways} > { { kind: "userPrompt" as const, text: "Initial prompt" }, { kind: "final" as const, content: "Initial reply" }, ]; - const secondReattach = deferred<{ sessionId: string; scrollback: ReplyChunk[] }>(); + const conversation = { + readPage: vi.fn(async () => ({ + turns: [ + { id: "p1", atMs: 1, role: "prompt" as const, source: { kind: "human" as const }, text: "Initial prompt", textLen: 14 }, + { id: "r1", atMs: 2, role: "response" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Initial reply", textLen: 13 }, + { id: "p2", atMs: 3, role: "prompt" as const, source: { kind: "human" as const }, text: "Question during graph view", textLen: 26 }, + { id: "r2", atMs: 4, role: "response" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Answer restored from replay", textLen: 27 }, + ], + hasMore: false, + })), + }; const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi @@ -1518,8 +1506,7 @@ describe("CustomAgentChatView", () => { .mockImplementationOnce(async (sessionId: string) => ({ sessionId, scrollback, - })) - .mockImplementationOnce(() => secondReattach.promise), + })), detachAgentChat: vi.fn(async () => {}), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), @@ -1542,6 +1529,7 @@ describe("CustomAgentChatView", () => { null) }, } as unknown as Gateways} > @@ -1553,17 +1541,11 @@ describe("CustomAgentChatView", () => { 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( null) }, } as unknown as Gateways} > @@ -1575,13 +1557,10 @@ describe("CustomAgentChatView", () => { 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(); + expect(agent.reattachAgentChat).toHaveBeenCalledTimes(1); }); it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => { @@ -2172,7 +2151,53 @@ describe("CustomAgentChatView", () => { expect(screen.getByRole("button", { name: "Mettre en attente" })).toBeTruthy(); }); - it("falls back to a fresh launch when reattach reports NOT_FOUND", async () => { + it("hides remembered announcements while no live session is attached (#366)", async () => { + const system = new MockSystemGateway(); + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + + + + + , + ); + + await act(async () => { + await Promise.resolve(); + system.emit({ + type: "agentAnnouncement", + projectId: "project-1", + requester: "main", + target: "architect", + ticketId: "delegated-1", + text: "ancienne commande shell", + atMs: 1, + }); + }); + + expect(screen.queryByTestId("announcements-preview")).toBeNull(); + expect(screen.queryByText("ancienne commande shell")).toBeNull(); + }); + + it("falls back to durable history when a persisted live session is gone", async () => { const agent = { launchAgentChat: vi.fn(async () => ({ sessionId: "chat-session-2", @@ -2181,21 +2206,22 @@ describe("CustomAgentChatView", () => { 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: [], - })), + .mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" }), sendAgentChat: vi.fn(() => new Promise(() => {})), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const onSessionId = vi.fn(); + const conversation = { readPage: vi.fn(async () => ({ + turns: [{ id: "r1", atMs: 1, role: "response" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Durable fallback", textLen: 16 }], + hasMore: false, + })) }; render( null) }, } as unknown as Gateways} > @@ -2214,18 +2240,9 @@ describe("CustomAgentChatView", () => { , ); - 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(await screen.findByText("Durable fallback")).toBeTruthy(); + expect(agent.launchAgentChat).not.toHaveBeenCalled(); + expect(onSessionId).toHaveBeenCalledWith(null); expect(screen.queryByText(/structured session gone/)).toBeNull(); }); @@ -5657,7 +5674,7 @@ describe("CustomAgentChatView", () => { expect(composer.className).toContain("shrink-0"); }); - it("falls back when Tauri surfaces structured session NOT_FOUND as a raw string", async () => { + it("falls back to durable history when Tauri reports a missing session as text", async () => { const agent = { launchAgentChat: vi.fn(async () => ({ sessionId: "chat-session-2", @@ -5670,21 +5687,22 @@ describe("CustomAgentChatView", () => { ) .mockRejectedValueOnce( "not found: structured session 69b57638-0ac7-4a79-8252-e0436a3265f6", - ) - .mockImplementationOnce(async (sessionId: string) => ({ - sessionId, - scrollback: [], - })), + ), sendAgentChat: vi.fn(() => new Promise(() => {})), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const onSessionId = vi.fn(); + const conversation = { readPage: vi.fn(async () => ({ + turns: [{ id: "r1", atMs: 1, role: "response" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Historique durable", textLen: 18 }], + hasMore: false, + })) }; render( null) }, } as unknown as Gateways} > @@ -5703,7 +5721,7 @@ describe("CustomAgentChatView", () => { , ); - await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1)); + expect(await screen.findByText("Historique durable")).toBeTruthy(); expect(agent.reattachAgentChat).toHaveBeenNthCalledWith( 1, "69b57638-0ac7-4a79-8252-e0436a3265f6", @@ -5715,13 +5733,8 @@ describe("CustomAgentChatView", () => { 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(agent.launchAgentChat).not.toHaveBeenCalled(); expect(screen.queryByText(/not found: structured session/)).toBeNull(); }); @@ -5751,6 +5764,7 @@ describe("CustomAgentChatView", () => { } as unknown as Gateways} > { } as unknown as Gateways} > { } as unknown as Gateways} > { } as unknown as Gateways} > { render( ({ turns: history, hasMore: false })) }, system: { pickFile: vi.fn(async () => null) }, } as unknown as Gateways}> { expect(agent.sendAgentChat).not.toHaveBeenCalled(); fireEvent.change(screen.getByRole("textbox"), { target: { value: "Continue" } }); + await waitFor(() => + expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(false), + ); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); expect(await screen.findByText("Conversation poursuivie")).toBeTruthy(); - expect(await screen.findByText("New final")).toBeTruthy(); + await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith( + "resumed-session", + "Continue", + expect.any(Function), + )); }); it("uses /clean for a new conversation and disables conversation changes during a turn", async () => { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 469b9912..24b031a3 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -1069,9 +1069,8 @@ export function CustomAgentChatView({ () => new Set(), ); const [currentSession, setCurrentSession] = useState(sessionId); - // A newly opened custom CLI intentionally starts blank. A conversation id - // persisted on the layout is only reused when it still has a live session; - // durable threads are reopened explicitly from the Conversations menu. + // `freshOnOpen` is reserved for an explicit reset intent. Ordinary React + // remounts keep the durable conversation selected by the hosting cell. const [activeConversationId, setActiveConversationId] = useState( sessionId || !freshOnOpen ? conversationId : null, ); @@ -1113,6 +1112,10 @@ export function CustomAgentChatView({ const [opening, setOpening] = useState( Boolean(sessionId || (!freshOnOpen && conversationId)), ); + const [liveSessionAttached, setLiveSessionAttached] = useState(false); + const [savedConversationLoadState, setSavedConversationLoadState] = useState< + "idle" | "loading" | "loaded" | "error" | "notFound" + >(!sessionId && !freshOnOpen && conversationId ? "loading" : "idle"); const [openRetryNonce, setOpenRetryNonce] = useState(0); const [transcriptUnavailableReason, setTranscriptUnavailableReason] = useState< ConversationTranscriptUnavailableReason | "conversationNotFound" | null @@ -1904,6 +1907,7 @@ export function CustomAgentChatView({ }); if (!mountedRef.current) return reattached.sessionId; setCurrentSession(reattached.sessionId); + setLiveSessionAttached(true); if (reattached.sessionId !== sid) { publishSessionId(reattached.sessionId); } @@ -2124,10 +2128,18 @@ export function CustomAgentChatView({ return; } setExternalSessionId(sessionId); + setLiveSessionAttached(false); }, [sessionId]); useEffect(() => { if (!supported) return; + if (!externalSessionId && !activeConversationIdRef.current && !freshOnOpen) { + setOpening(false); + setError(null); + setSavedConversationLoadState("idle"); + setLiveSessionAttached(false); + return; + } let cancelled = false; async function openOrAttach() { @@ -2152,6 +2164,8 @@ export function CustomAgentChatView({ return; } if (externalSessionId) { + setSavedConversationLoadState("idle"); + setLiveSessionAttached(false); const cached = chatSessionViewCache.get(externalSessionId); if (cached) { scrollbackChunkCountRef.current = cached.chunkCount; @@ -2194,6 +2208,8 @@ export function CustomAgentChatView({ } if (!cancelled) { setCurrentSession(null); + setExternalSessionId(null); + setLiveSessionAttached(false); publishSessionId(null); } console.debug("[ticket149] openOrAttach:reattach-existing:not-found", { @@ -2202,6 +2218,7 @@ export function CustomAgentChatView({ sessionId: externalSessionId, error: instrumentationError(e), }); + return; } } else { const cacheKey = conversationCacheKey( @@ -2217,32 +2234,45 @@ export function CustomAgentChatView({ progressIndex: cached.progressIndex ?? new Map(), progressScope: cached.progressScope ?? 0, }); - if (cached.sessionId) { - const reattachedSessionId = await reattachStructuredSession(cached.sessionId, { - applyScrollback: true, + } + const savedConversationId = activeConversationIdRef.current; + if (!savedConversationId) { + setSavedConversationLoadState("idle"); + setLiveSessionAttached(false); + if (freshOnOpen) { + await recoverStructuredSession({ + applyScrollback: false, + retryAttachNotFound: true, + conversationId: null, }); - if (cancelled) return; - publishSessionId(reattachedSessionId); - console.debug("[ticket149] openOrAttach:reattach-conversation-cache:success", { - timestamp: new Date().toISOString(), - execution, - sessionId: reattachedSessionId, - }); - return; } return; } + + setSavedConversationLoadState("loading"); + setLiveSessionAttached(false); + try { + const page = await conversation.readPage(projectId, savedConversationId, { + direction: "backward", + limit: 50, + }); + if (cancelled) return; + applyChatViewState({ + ...emptyChatViewState(), + turns: durableHistoryTurns(page.turns), + }); + setResumedConversation(true); + setConversationNotice(null); + setHistoryHasMore(page.hasMore); + setHistoryNextAnchor(page.nextAnchor); + setHistoryError(null); + setSavedConversationLoadState("loaded"); + } catch (e) { + if (cancelled) return; + setSavedConversationLoadState(isNotFound(e) ? "notFound" : "error"); + } + return; } - await recoverStructuredSession({ - applyScrollback: Boolean(activeConversationIdRef.current), - retryAttachNotFound: true, - }); - if (cancelled) return; - console.debug("[ticket149] openOrAttach:recover:success", { - timestamp: new Date().toISOString(), - execution, - receivedSessionId: externalSessionId, - }); } catch (e) { console.error("[ticket149] openOrAttach:error", { timestamp: new Date().toISOString(), @@ -2273,6 +2303,10 @@ export function CustomAgentChatView({ recoverStructuredSession, publishSessionId, applyChatViewState, + conversation, + projectId, + agentId, + freshOnOpen, openRetryNonce, ]); @@ -2280,8 +2314,10 @@ export function CustomAgentChatView({ () => supported && (Boolean(draft.trim()) || attachments.length > 0) && - !opening, - [supported, draft, attachments.length, opening], + !opening && + savedConversationLoadState !== "error" && + savedConversationLoadState !== "notFound", + [supported, draft, attachments.length, opening, savedConversationLoadState], ); const unavailableLinkedTranscript = displayTurns.length === 0 && @@ -2289,16 +2325,17 @@ export function CustomAgentChatView({ transcriptUnavailableReason === "conversationNotFound") ? transcriptUnavailableReason : null; - - function unlinkUnavailableConversation() { - onConversationIdRef.current(null); - setTranscriptUnavailableReason("emptyConversation"); - setError(null); - } + const savedConversationLoadFailure = + savedConversationLoadState === "error" || savedConversationLoadState === "notFound" + ? savedConversationLoadState + : null; function retryUnavailableTranscript() { const sid = sessionRef.current; setTranscriptUnavailableReason(null); + setSavedConversationLoadState( + !sid && activeConversationIdRef.current ? "loading" : "idle", + ); setError(null); if (sid) { // Force the next user-triggered pass through the attach path. Keeping the @@ -2343,9 +2380,11 @@ export function CustomAgentChatView({ if (sid) await agent.closeAgentChat?.(sid); setCurrentSession(null); setExternalSessionId(null); + setLiveSessionAttached(false); publishSessionId(null); publishConversationId(nextConversationId); applyChatViewState(emptyChatViewState()); + setSavedConversationLoadState("loading"); setResumedConversation(false); setConversationNotice(null); setHistoryHasMore(false); @@ -2492,6 +2531,7 @@ export function CustomAgentChatView({ (await recoverStructuredSession({ applyScrollback: false, retryAttachNotFound: true, + ...(invocation.name === "/clean" ? { conversationId: null } : {}), })); const result = await agent.executeSlashCommand(invocation.name, { sessionId: sid, @@ -2513,6 +2553,7 @@ export function CustomAgentChatView({ setExternalSessionId(result.effect.sessionId); publishSessionId(result.effect.sessionId); publishConversationId(result.effect.conversationId ?? null); + setSavedConversationLoadState("idle"); setResumedConversation(false); setHistoryHasMore(false); setHistoryNextAnchor(undefined); @@ -3247,13 +3288,15 @@ export function CustomAgentChatView({ className="relative z-10 flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3" onScroll={threadScroll.onScroll} > -
- -
+ {liveSessionAttached && delegationActive && ( +
+ +
+ )} {conversationNotice && (

{conversationNotice} @@ -3291,18 +3334,26 @@ export function CustomAgentChatView({

- {externalSessionId ? t("custom.resume") : t("custom.opening")} + {savedConversationLoadState === "loading" + ? tr("Chargement de la conversation…", "Loading conversation…") + : externalSessionId + ? t("custom.resume") + : t("custom.opening")}
- ) : unavailableLinkedTranscript ? ( + ) : savedConversationLoadFailure || unavailableLinkedTranscript ? (

- {tr("La conversation liée n’a pas pu être restaurée.", "The linked conversation could not be restored.")} -

-

- {unavailableLinkedTranscript === "conversationNotFound" - ? tr("Cette conversation est introuvable dans le stockage disponible.", "This conversation cannot be found in available storage.") - : tr("Sa transcription n’est plus disponible après la reprise.", "Its transcript is no longer available after restoration.")} + {savedConversationLoadFailure === "notFound" || + unavailableLinkedTranscript === "conversationNotFound" + ? tr( + "Cette conversation sauvegardée est introuvable. Vous pouvez réessayer ou ouvrir une nouvelle conversation.", + "This saved conversation could not be found. You can retry or open a new conversation.", + ) + : tr( + "Impossible de charger cette conversation sauvegardée. Vos messages n'ont pas été supprimés. Réessayez ou ouvrez explicitement une nouvelle conversation.", + "Unable to load this saved conversation. Your messages have not been deleted. Retry or explicitly open a new conversation.", + )}

-
+ ) : savedConversationLoadState === "loaded" && displayTurns.length === 0 ? ( +
+ {tr( + "Cette conversation sauvegardée ne contient aucun message affichable.", + "This saved conversation contains no displayable messages.", + )} +
) : displayTurns.length === 0 ? (
@@ -3565,7 +3623,7 @@ export function CustomAgentChatView({ )} rows={2} value={draft} - disabled={!supported || opening} + disabled={!supported || opening || Boolean(savedConversationLoadFailure)} placeholder={t("custom.message")} style={{ height: clampComposerHeight(composerHeightPx) }} onChange={(e) => editDraft(e.target.value)} @@ -3623,7 +3681,7 @@ export function CustomAgentChatView({ size="sm" variant="ghost" className="shrink-0 whitespace-nowrap" - disabled={!supported || opening} + disabled={!supported || opening || Boolean(savedConversationLoadFailure)} onClick={() => void pickAttachment()} > {t("custom.attach")} diff --git a/frontend/src/features/layout/LayoutGrid.chat.test.tsx b/frontend/src/features/layout/LayoutGrid.chat.test.tsx index e38f6cb5..0b062f0c 100644 --- a/frontend/src/features/layout/LayoutGrid.chat.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.chat.test.tsx @@ -47,6 +47,7 @@ import type { AgentProfile } from "@/domain"; import type { Gateways } from "@/ports"; import { MockAgentGateway, + MockConversationGateway, MockLayoutGateway, MockProfileGateway, MockSystemGateway, @@ -97,6 +98,7 @@ async function seeded(profile: AgentProfile): Promise { const terminal = new MockTerminalGateway(); const system = new MockSystemGateway(); const workState = new MockWorkStateGateway(); + const conversation = new MockConversationGateway(); await profileGateway.configureProfiles([profile]); const created = await agent.createAgent("p1", { name: "Worker", @@ -116,6 +118,7 @@ async function seeded(profile: AgentProfile): Promise { terminal, system, workState, + conversation, } as unknown as Gateways; } @@ -175,6 +178,9 @@ describe("LayoutGrid custom agent CLI (#147)", () => { fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), { target: { value: "hello there" }, }); + await waitFor(() => + expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(false), + ); fireEvent.click(screen.getByText("Envoyer")); await waitFor(() => expect(screen.getByText("Terminé")).toBeTruthy()); @@ -204,13 +210,14 @@ describe("LayoutGrid custom agent CLI (#147)", () => { expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom"); }); - it("restores a project custom CLI cell after switching away and back without rewriting durable ids", async () => { + it("restores durable messages after project A → B → A without starting a provider", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const profileGateway = new MockProfileGateway(); const terminal = new MockTerminalGateway(); const system = new MockSystemGateway(); const workState = new MockWorkStateGateway(); + const conversation = new MockConversationGateway(); await profileGateway.configureProfiles([structuredProfile]); const agentA = await agent.createAgent("p1", { @@ -229,6 +236,15 @@ describe("LayoutGrid custom agent CLI (#147)", () => { target: leafA.id, preferredView: "chat", }); + await layout.mutateLayout("p1", { + type: "setCellConversation", + target: leafA.id, + conversationId: "saved-a", + }); + conversation._setTurns("p1", "saved-a", [ + { id: "p1", atMs: 1, role: "prompt", source: { kind: "human" }, text: "Question projet A", textLen: 17 }, + { id: "r1", atMs: 2, role: "response", source: { kind: "agent", agentId: agentA.id }, text: "Réponse projet A", textLen: 16 }, + ]); const agentB = await agent.createAgent("p2", { name: "Worker B", @@ -249,17 +265,14 @@ describe("LayoutGrid custom agent CLI (#147)", () => { terminal, system, workState, + conversation, } as unknown as Gateways; + const launchChat = vi.spyOn(agent, "launchAgentChat"); const view = renderGrid(gateways, "p1"); expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); - await waitFor(async () => { - const updated = await layout.loadLayout("p1"); - const leaf = leaves(updated)[0]; - expect(leaf.preferredView).toBe("chat"); - expect(leaf.session).toMatch(/^mock-agent-chat-/); - expect(leaf.conversationId).toMatch(/^mock-conversation-mock-agent-chat-/); - }); + expect(await screen.findByText("Question projet A")).toBeTruthy(); + expect(screen.getByText("Réponse projet A")).toBeTruthy(); const persistedA = leaves(await layout.loadLayout("p1"))[0]; view.rerender( @@ -280,10 +293,13 @@ describe("LayoutGrid custom agent CLI (#147)", () => { expect(screen.queryByRole("alertdialog", { name: "Confirmer le changement de CLI" })) .toBeNull(); expect(screen.queryByText(/Aucune conversation reprenable/)).toBeNull(); + expect(await screen.findByText("Question projet A")).toBeTruthy(); + expect(screen.getByText("Réponse projet A")).toBeTruthy(); const restoredA = leaves(await layout.loadLayout("p1"))[0]; expect(restoredA.preferredView).toBe("chat"); expect(restoredA.session).toBe(persistedA.session); expect(restoredA.conversationId).toBe(persistedA.conversationId); + expect(launchChat).not.toHaveBeenCalled(); }); it("keeps custom CLI and composer active while busy, then queues without sending (#247/#352)", async () => { @@ -343,7 +359,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => { expect(sendAgentChat).not.toHaveBeenCalled(); }); - it("renders requester-side delegation fallback in custom mode after remount (#232)", async () => { + it("does not render requester announcements without a live attached session (#366)", async () => { const gateways = await seeded(structuredProfile); const layout = gateways.layout as MockLayoutGateway; const workState = gateways.workState as MockWorkStateGateway; @@ -386,13 +402,13 @@ describe("LayoutGrid custom agent CLI (#147)", () => { const view = renderGrid(gateways); expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); - expect(await screen.findByText("Worker attend Architect")).toBeTruthy(); + expect(screen.queryByText("Worker attend Architect")).toBeNull(); view.unmount(); renderGrid(gateways); expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); - expect(await screen.findByText("Worker attend Architect")).toBeTruthy(); + expect(screen.queryByText("Worker attend Architect")).toBeNull(); }); it("persists custom CLI composer height per layout cell", async () => { @@ -438,7 +454,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => { } }); - it("opens the custom CLI even when the agent already has a structured background session (#169)", async () => { + it("does not attach an unrelated structured background session implicitly (#169/#366)", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const profileGateway = new MockProfileGateway(); @@ -476,17 +492,18 @@ describe("LayoutGrid custom agent CLI (#147)", () => { await waitFor(() => expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(), ); - await waitFor(() => expect(launchChat).toHaveBeenCalled()); - expect(launchChat.mock.calls.at(-1)?.[2]).toMatchObject({ nodeId: leafId }); + expect(await screen.findByText("Nouvelle conversation")).toBeTruthy(); + expect(launchChat).not.toHaveBeenCalled(); expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull(); }); - it("starts a fresh custom CLI conversation when returning without a live session (#363)", async () => { + it("restores a saved custom CLI conversation when returning without a live session (#366)", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const profileGateway = new MockProfileGateway(); const terminal = new MockTerminalGateway(); const system = new MockSystemGateway(); + const conversation = new MockConversationGateway(); await profileGateway.configureProfiles([structuredProfile]); const created = await agent.createAgent("p1", { name: "Worker", @@ -505,6 +522,10 @@ describe("LayoutGrid custom agent CLI (#147)", () => { conversationId: "conversation-known", }); window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom"); + conversation._setTurns("p1", "conversation-known", [ + { id: "p1", atMs: 1, role: "prompt", source: { kind: "human" }, text: "known prompt", textLen: 12 }, + { id: "r1", atMs: 2, role: "response", source: { kind: "agent", agentId: created.id }, text: "known response", textLen: 14 }, + ]); const launchChat = vi.spyOn(agent, "launchAgentChat").mockResolvedValue({ sessionId: "chat-fresh", cellKind: "chat" as const, @@ -521,27 +542,17 @@ describe("LayoutGrid custom agent CLI (#147)", () => { profile: profileGateway, terminal, system, + conversation, } as unknown as Gateways); - expect(await screen.findByText("Nouvelle conversation")).toBeTruthy(); - expect(screen.queryByText("known prompt")).toBeNull(); - expect(launchChat).toHaveBeenCalledWith( - "p1", - created.id, - expect.objectContaining({ - nodeId: leafId, - conversationId: undefined, - }), - ); - expect(reattachChat).toHaveBeenCalledWith("chat-fresh", expect.any(Function)); - await waitFor(async () => { - const updated = await layout.loadLayout("p1"); - const leaf = leaves(updated)[0]; - expect(leaf.conversationId).toBe("conversation-fresh"); - expect(leaf.session).toBe("chat-fresh"); - }); - expect(screen.getByText("Envoyez un message pour démarrer la conversation structurée.")) - .toBeTruthy(); + expect(await screen.findByText("known prompt")).toBeTruthy(); + expect(screen.getByText("known response")).toBeTruthy(); + expect(screen.queryByText("Nouvelle conversation")).toBeNull(); + expect(launchChat).not.toHaveBeenCalled(); + expect(reattachChat).not.toHaveBeenCalled(); + const updated = await layout.loadLayout("p1"); + expect(leaves(updated)[0].conversationId).toBe("conversation-known"); + expect(leaves(updated)[0].session).toBeNull(); }); it("relaunches a stopped custom CLI agent from scratch (#197)", async () => { @@ -646,6 +657,10 @@ describe("LayoutGrid custom agent CLI (#147)", () => { await waitFor(() => expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(), ); + fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), { + target: { value: "Start live chat" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(async () => { const updated = await layout.loadLayout("p1"); expect(leaves(updated)[0].session).toMatch(/^mock-agent-chat-/); @@ -701,6 +716,10 @@ describe("LayoutGrid custom agent CLI (#147)", () => { ); expect(stopLive).not.toHaveBeenCalled(); expect(closeTerminal).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), { + target: { value: "Start structured chat" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(launchChat).toHaveBeenCalled()); expect(launchChat.mock.calls.at(-1)?.[2]).toMatchObject({ nodeId: leafId }); await waitFor(async () => { diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 35ae8aa4..ed6a7b0e 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -1695,7 +1695,6 @@ function LeafView({ ) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? ( { it("mounts and renders the cell without throwing (headless-safe)", async () => { const { agent, profile, agentId } = await seededAgentGateway(); @@ -113,6 +119,7 @@ describe("WebAgentCell", () => { expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); expect(screen.queryByTestId("terminal-key-bar")).toBeNull(); + await startCustomConversation(); await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1)); expect(launchChatSpy.mock.calls[0][2]).toMatchObject({ cwd: "/srv/demo" }); expect(launchChatSpy.mock.calls[0][2]).not.toHaveProperty("nodeId"); @@ -128,6 +135,7 @@ describe("WebAgentCell", () => { nodeId: leafNodeId, }); fireEvent.click(await screen.findByRole("button", { name: "CLI custom" })); + await startCustomConversation(); await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1)); expect(launchChatSpy.mock.calls[0][2]).toMatchObject({ @@ -162,6 +170,7 @@ describe("WebAgentCell", () => { renderCell(agent, profile, agentId, { fullHeight: true }); fireEvent.click(await screen.findByRole("button", { name: "CLI custom" })); + await startCustomConversation(); await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1)); await waitFor(() => expect(reattachChatSpy).toHaveBeenCalledTimes(1)); @@ -186,6 +195,7 @@ describe("WebAgentCell", () => { renderCell(agent, profile, agentId, { fullHeight: true }); fireEvent.click(await screen.findByRole("button", { name: "CLI custom" })); + await startCustomConversation(); await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1)); const sessionId = (await launchChatSpy.mock.results[0].value).sessionId; @@ -197,7 +207,7 @@ describe("WebAgentCell", () => { expect(launchChatSpy).toHaveBeenCalledTimes(1); }); - it("surfaces a generic mobile custom CLI launch failure", async () => { + it("surfaces a user-triggered mobile custom CLI launch failure", async () => { const { agent, profile, agentId } = await seededAgentGateway(structuredProfile); const launchChatSpy = vi.spyOn(agent, "launchAgentChat").mockRejectedValueOnce({ code: "PROCESS", @@ -207,14 +217,14 @@ describe("WebAgentCell", () => { renderCell(agent, profile, agentId, { fullHeight: true }); fireEvent.click(await screen.findByRole("button", { name: "CLI custom" })); + await startCustomConversation(); await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1)); expect(launchChatSpy.mock.calls[0][2]).toMatchObject({ cwd: "/srv/demo" }); expect(launchChatSpy.mock.calls[0][2]).not.toHaveProperty("nodeId"); expect(reattachChatSpy).not.toHaveBeenCalled(); const alertText = (await screen.findByRole("alert")).textContent ?? ""; - expect(alertText).toContain("Le CLI custom n'a pas pu démarrer"); - expect(alertText).not.toContain("invalid node id"); + expect(alertText).toContain("invalid node id: web-mobile-agent-1"); }); it("falls back to native TUI when the mobile agent loses custom CLI support", async () => { diff --git a/frontend/src/features/web/WebAgentCell.tsx b/frontend/src/features/web/WebAgentCell.tsx index 4305d329..dc3fe6f8 100644 --- a/frontend/src/features/web/WebAgentCell.tsx +++ b/frontend/src/features/web/WebAgentCell.tsx @@ -200,7 +200,6 @@ export function WebAgentCell({ )} {mode === "custom" && showModeSwitch && resolvedAgent && resolvedProfile ? (