diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 207603a6..74f96608 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -433,6 +433,73 @@ describe("CustomAgentChatView", () => { expect(agent.sendAgentChat).not.toHaveBeenCalled(); }); + it("reattaches a live conversation on remount without reading durable history (#370)", async () => { + let receive: ((chunk: ReplyChunk) => void) | undefined; + const agent = { + launchAgentChat: vi.fn(), + listLiveAgents: vi.fn(async () => [{ + agentId: "agent-1", + nodeId: "node-1", + sessionId: "live-session", + kind: "structured" as const, + runtimeState: "running" as const, + }]), + reattachAgentChat: vi.fn(async ( + sessionId: string, + onChunk: (chunk: ReplyChunk) => void, + ) => { + receive = onChunk; + return { + sessionId, + conversationId: "active-conversation", + scrollback: [ + { kind: "userPrompt" as const, text: "Question active" }, + { kind: "textDelta" as const, text: "Réponse en cours" }, + ], + }; + }), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + const conversation = { + readPage: vi.fn(async () => ({ turns: [], hasMore: false })), + }; + const onSessionId = vi.fn(); + + render( + null) }, + } as unknown as Gateways}> + + , + ); + + expect(await screen.findByText("Question active")).toBeTruthy(); + expect(screen.getAllByText("Réponse en cours").length).toBeGreaterThan(0); + expect(conversation.readPage).not.toHaveBeenCalled(); + expect(screen.queryByText("Conversation reprise")).toBeNull(); + expect(onSessionId).toHaveBeenCalledWith("live-session"); + + act(() => receive?.({ kind: "final", content: "Réponse terminée" })); + expect(await screen.findByText("Réponse terminée")).toBeTruthy(); + expect(screen.getByText("Question active")).toBeTruthy(); + expect(screen.getAllByText("Réponse en cours").length).toBeGreaterThan(0); + }); + it("keeps a durable load failure distinct and retries without clearing its conversation id", async () => { const agent = { launchAgentChat: vi.fn(), @@ -1602,7 +1669,7 @@ describe("CustomAgentChatView", () => { launchAgentChat: vi.fn(), reattachAgentChat: vi .fn() - .mockImplementationOnce(async (sessionId: string) => ({ + .mockImplementation(async (sessionId: string) => ({ sessionId, scrollback, })), @@ -1639,6 +1706,11 @@ describe("CustomAgentChatView", () => { expect(await screen.findByText("Initial reply")).toBeTruthy(); first.unmount(); await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1")); + scrollback = [ + ...scrollback, + { kind: "userPrompt", text: "Question during graph view" }, + { kind: "final", content: "Answer restored from replay" }, + ]; render( { 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); + expect(agent.reattachAgentChat).toHaveBeenCalledTimes(2); + expect(conversation.readPage).not.toHaveBeenCalled(); + expect(screen.queryByText("Conversation reprise")).toBeNull(); }); it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index a6944dc7..7018bf39 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -1885,6 +1885,7 @@ export function CustomAgentChatView({ applyScrollback?: boolean; stableIdentity?: boolean; preserveRestartTurns?: boolean; + preserveViewOnEmptyScrollback?: boolean; } = {}, ) => { if (!agent.reattachAgentChat) throw new Error("Structured reattach unavailable"); @@ -1921,7 +1922,10 @@ export function CustomAgentChatView({ if (reattached.conversationId) { publishConversationId(reattached.conversationId); } - if (options.applyScrollback) { + if ( + options.applyScrollback && + (reattached.scrollback.length > 0 || !options.preserveViewOnEmptyScrollback) + ) { scrollbackChunkCountRef.current = reattached.scrollback.length; let replayed = reattached.scrollback.reduce( (state, chunk) => foldChunk(state, chunk), @@ -2214,6 +2218,21 @@ export function CustomAgentChatView({ if (!isNotFound(stableError)) throw stableError; } if (!cancelled) { + chatSessionViewCache.delete(externalSessionId); + const cacheKey = conversationCacheKey( + projectId, + agentId, + activeConversationIdRef.current, + ); + const cached = cacheKey ? chatConversationViewCache.get(cacheKey) : undefined; + if (cacheKey && cached?.sessionId === externalSessionId) { + chatConversationViewCache.set(cacheKey, { + turns: cached.turns, + progressIndex: cached.progressIndex, + progressScope: cached.progressScope, + chunkCount: cached.chunkCount, + }); + } setCurrentSession(null); setExternalSessionId(null); setLiveSessionAttached(false); @@ -2256,6 +2275,51 @@ export function CustomAgentChatView({ return; } + // A cell can remount while its structured runtime remains live (for + // example after switching tabs). The layout session can temporarily + // be absent, but the conversation cache and live registry still carry + // the runtime identity. Reattach that runtime before considering the + // durable transcript: a conversation id alone is not a request to + // replace an active view with saved history (#370). + const cachedSessionId = cached?.sessionId; + if (cachedSessionId) { + try { + const attachedSessionId = await reattachStructuredSession(cachedSessionId, { + applyScrollback: true, + stableIdentity: true, + preserveViewOnEmptyScrollback: true, + }); + if (cancelled) return; + publishSessionId(attachedSessionId); + setSavedConversationLoadState("idle"); + setResumedConversation(false); + return; + } catch (e) { + if (!isNotFound(e)) throw e; + } + } + + const live = agent.listLiveAgents + ? (await agent.listLiveAgents(projectId)).find( + (candidate) => candidate.agentId === agentId && candidate.kind === "structured", + ) + : undefined; + if (live) { + const rebound = + nodeId && live.nodeId !== nodeId && agent.attachLiveAgent + ? await agent.attachLiveAgent(projectId, agentId, nodeId) + : live; + const attachedSessionId = await reattachStructuredSession(rebound.sessionId, { + applyScrollback: true, + preserveViewOnEmptyScrollback: true, + }); + if (cancelled) return; + publishSessionId(attachedSessionId); + setSavedConversationLoadState("idle"); + setResumedConversation(false); + return; + } + setSavedConversationLoadState("loading"); setLiveSessionAttached(false); try { @@ -2311,8 +2375,10 @@ export function CustomAgentChatView({ publishSessionId, applyChatViewState, conversation, + agent, projectId, agentId, + nodeId, freshOnOpen, openRetryNonce, ]);