From 581063cf964aba28697f1fb59db220c01e542b5a Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 15 Aug 2026 19:32:56 +0200 Subject: [PATCH] =?UTF-8?q?fix(cli):=20restaure=20la=20conversation=20au?= =?UTF-8?q?=20retour=20de=20layout=20via=20cache=20conversationId+sessionI?= =?UTF-8?q?d=20=E2=80=94=20#217?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agents/CustomAgentChatView.test.tsx | 79 +++++++++++++ .../features/agents/CustomAgentChatView.tsx | 104 ++++++++++++++++-- .../features/layout/LayoutGrid.chat.test.tsx | 65 +++++++++++ 3 files changed, 236 insertions(+), 12 deletions(-) diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index f9d45687..18e15003 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -522,6 +522,85 @@ describe("CustomAgentChatView", () => { expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true); }); + it("restores visible conversation from conversationId cache when layout returns before sessionId", async () => { + let scrollback: ReplyChunk[] = [ + { kind: "userPrompt" as const, text: "Initial prompt" }, + { kind: "final" as const, content: "Initial reply" }, + ]; + const secondReattach = deferred<{ sessionId: string; scrollback: ReplyChunk[] }>(); + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi + .fn() + .mockImplementationOnce(async (sessionId: string) => ({ + sessionId, + scrollback, + })) + .mockImplementationOnce(() => secondReattach.promise), + detachAgentChat: vi.fn(async () => {}), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + const props = { + projectId: "project-1", + agentId: "agent-1", + agentName: "Worker", + profile, + cwd: "/repo", + nodeId: "node-1", + sessionId: "chat-session-1" as string | null, + conversationId: "conversation-1", + onSessionId: vi.fn(), + onConversationId: vi.fn(), + }; + + const first = render( + null) }, + } as unknown as Gateways} + > + + , + ); + + expect(await screen.findByText("Initial reply")).toBeTruthy(); + 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} + > + + , + ); + + expect(screen.getByText("Initial reply")).toBeTruthy(); + 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(); + }); + it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => { const agent = { launchAgentChat: vi.fn(), diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 113f9ada..6439ac46 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -67,15 +67,18 @@ interface CachedChatView { turns: ChatTurn[]; progressIndex: Map; chunkCount: number; + sessionId?: string; } const chatSessionViewCache = new Map(); +const chatConversationViewCache = new Map(); const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse."; const COMPOSER_RESIZE_STEP = 12; const FALLBACK_COMPOSER_HEIGHT = 64; export function __clearCustomAgentChatViewCacheForTests() { chatSessionViewCache.clear(); + chatConversationViewCache.clear(); } interface AttachmentDraft { @@ -310,6 +313,14 @@ function emptyChatViewState(): ChatViewState { return { turns: [], progressIndex: new Map() }; } +function conversationCacheKey( + projectId: string, + agentId: string, + conversationId: string | null, +): string | null { + return conversationId ? `${projectId}::${agentId}::${conversationId}` : null; +} + function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState { const key = progressUpsertKey(progress); const existingIndex = state.turns.findIndex( @@ -711,10 +722,27 @@ export function CustomAgentChatView({ }: CustomAgentChatViewProps) { const { agent, profile: profileGateway, system } = useGateways(); const pluginRuntime = usePluginRuntime(); - const [turns, setTurns] = useState( - () => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []), + const initialConversationCacheKey = conversationCacheKey( + projectId, + agentId, + conversationId, + ); + const [turns, setTurns] = useState( + () => + sessionId + ? (chatSessionViewCache.get(sessionId)?.turns ?? []) + : initialConversationCacheKey + ? (chatConversationViewCache.get(initialConversationCacheKey)?.turns ?? []) + : [], + ); + const [progressIndex, setProgressIndex] = useState>( + () => + sessionId + ? (chatSessionViewCache.get(sessionId)?.progressIndex ?? new Map()) + : initialConversationCacheKey + ? (chatConversationViewCache.get(initialConversationCacheKey)?.progressIndex ?? new Map()) + : new Map(), ); - const [progressIndex, setProgressIndex] = useState>(new Map()); const [currentSession, setCurrentSession] = useState(sessionId); const [externalSessionId, setExternalSessionId] = useState(sessionId); const [draft, setDraft] = useState(""); @@ -724,7 +752,7 @@ export function CustomAgentChatView({ const [slashActiveIndex, setSlashActiveIndex] = useState(0); const [profileDialog, setProfileDialog] = useState(null); - const [opening, setOpening] = useState(Boolean(sessionId)); + const [opening, setOpening] = useState(Boolean(sessionId || conversationId)); const [busy, setBusy] = useState(false); const [stopping, setStopping] = useState(false); const [error, setError] = useState(null); @@ -849,6 +877,10 @@ export function CustomAgentChatView({ () => (layoutId ? { projectId, layoutId, nodeId } : null), [layoutId, nodeId, projectId], ); + const currentConversationCacheKey = useMemo( + () => conversationCacheKey(projectId, agentId, conversationId), + [agentId, conversationId, projectId], + ); const displayTurns = turns; const clearPersistedDraft = useCallback(async () => { @@ -918,13 +950,27 @@ export function CustomAgentChatView({ ); useEffect(() => { - if (!currentSession) return; - chatSessionViewCache.set(currentSession, { - turns, - progressIndex, - chunkCount: scrollbackChunkCountRef.current, - }); - }, [currentSession, progressIndex, turns]); + if (currentSession) { + chatSessionViewCache.set(currentSession, { + turns, + progressIndex, + chunkCount: scrollbackChunkCountRef.current, + sessionId: currentSession, + }); + } + if (currentConversationCacheKey) { + const previous = chatConversationViewCache.get(currentConversationCacheKey); + const cachedSessionId = currentSession ?? previous?.sessionId; + if (cachedSessionId || turns.length > 0 || progressIndex.size > 0) { + chatConversationViewCache.set(currentConversationCacheKey, { + turns, + progressIndex, + chunkCount: scrollbackChunkCountRef.current, + ...(cachedSessionId ? { sessionId: cachedSessionId } : {}), + }); + } + } + }, [currentConversationCacheKey, currentSession, progressIndex, turns]); useEffect(() => { draftSaveSeqRef.current += 1; @@ -1216,6 +1262,15 @@ export function CustomAgentChatView({ setOpening(true); setError(null); try { + const localSession = sessionRef.current; + if (!externalSessionId && localSession) { + console.debug("[ticket149] openOrAttach:local-session-present:skip", { + timestamp: new Date().toISOString(), + execution, + currentSession: localSession, + }); + return; + } if (externalSessionId) { const cached = chatSessionViewCache.get(externalSessionId); if (cached) { @@ -1250,9 +1305,32 @@ export function CustomAgentChatView({ error: instrumentationError(e), }); } + } else if (currentConversationCacheKey) { + const cached = chatConversationViewCache.get(currentConversationCacheKey); + if (cached) { + scrollbackChunkCountRef.current = cached.chunkCount; + applyChatViewState({ + turns: cached.turns, + progressIndex: cached.progressIndex ?? new Map(), + }); + if (cached.sessionId) { + const reattachedSessionId = await reattachStructuredSession(cached.sessionId, { + applyScrollback: true, + }); + if (cancelled) return; + publishSessionId(reattachedSessionId); + console.debug("[ticket149] openOrAttach:reattach-conversation-cache:success", { + timestamp: new Date().toISOString(), + execution, + sessionId: reattachedSessionId, + }); + return; + } + return; + } } await recoverStructuredSession({ - applyScrollback: false, + applyScrollback: Boolean(conversationId), retryAttachNotFound: true, }); if (cancelled) return; @@ -1285,6 +1363,8 @@ export function CustomAgentChatView({ recoverStructuredSession, publishSessionId, applyChatViewState, + currentConversationCacheKey, + conversationId, ]); const canSend = useMemo( diff --git a/frontend/src/features/layout/LayoutGrid.chat.test.tsx b/frontend/src/features/layout/LayoutGrid.chat.test.tsx index b814b476..eeb2d032 100644 --- a/frontend/src/features/layout/LayoutGrid.chat.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.chat.test.tsx @@ -266,6 +266,71 @@ describe("LayoutGrid custom agent CLI (#147)", () => { expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull(); }); + it("restores a custom CLI transcript when returning with a conversation id but no session (#217)", async () => { + const layout = new MockLayoutGateway(); + const agent = new MockAgentGateway(); + const profileGateway = new MockProfileGateway(); + const terminal = new MockTerminalGateway(); + const system = new MockSystemGateway(); + await profileGateway.configureProfiles([structuredProfile]); + const created = await agent.createAgent("p1", { + name: "Worker", + profileId: structuredProfile.id, + }); + const tree = await layout.loadLayout("p1"); + const leafId = leaves(tree)[0].id; + await layout.mutateLayout("p1", { + type: "setCellAgent", + target: leafId, + agent: created.id, + }); + await layout.mutateLayout("p1", { + type: "setCellConversation", + target: leafId, + conversationId: "conversation-known", + }); + window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom"); + const launchChat = vi.spyOn(agent, "launchAgentChat").mockResolvedValue({ + sessionId: "chat-resumed", + cellKind: "chat" as const, + }); + const reattachChat = vi.spyOn(agent, "reattachAgentChat").mockResolvedValue({ + sessionId: "chat-resumed", + scrollback: [ + { kind: "userPrompt", text: "known prompt" }, + { kind: "final", content: "known answer" }, + ], + }); + + renderGrid({ + layout, + agent, + profile: profileGateway, + terminal, + system, + } as unknown as Gateways); + + expect(await screen.findByText("known answer")).toBeTruthy(); + expect(screen.getByText("known prompt")).toBeTruthy(); + expect(launchChat).toHaveBeenCalledWith( + "p1", + created.id, + expect.objectContaining({ + nodeId: leafId, + conversationId: "conversation-known", + }), + ); + expect(reattachChat).toHaveBeenCalledWith("chat-resumed", expect.any(Function)); + await waitFor(async () => { + const updated = await layout.loadLayout("p1"); + const leaf = leaves(updated)[0]; + expect(leaf.conversationId).toBe("conversation-known"); + expect(leaf.session).toBe("chat-resumed"); + }); + expect(screen.queryByText("Envoyez un message pour démarrer la conversation structurée.")) + .toBeNull(); + }); + it("relaunches a stopped custom CLI agent from scratch (#197)", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway();