diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 2bd9405c..0299b63f 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -2415,6 +2415,7 @@ pub async fn agent_send( let meta = state.structured_sessions.meta_for_session(&sid); std::thread::spawn(move || { let mut terminal_visible = false; + let mut streamed_text = String::new(); let mut settler = crate::chat::ProgressSettler::default(); for event in stream { // Non-terminal, sans contenu chat : un signal de limite alimente le service @@ -2445,6 +2446,9 @@ pub async fn agent_send( let Some(chunk) = crate::chat::chunk_from_event(event) else { continue; }; + if let ReplyChunk::TextDelta { text } = &chunk { + streamed_text.push_str(text); + } if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) { terminal_visible = true; } @@ -2455,13 +2459,16 @@ pub async fn agent_send( let _ = bridge.send_output_settled(&sid, &mut settler, chunk); } if !terminal_visible { - let _ = bridge.send_output_settled( - &sid, - &mut settler, + let chunk = if streamed_text.trim().is_empty() { ReplyChunk::Error { message: "Le modèle n'a renvoyé aucune réponse.".to_owned(), - }, - ); + } + } else { + ReplyChunk::Final { + content: streamed_text, + } + }; + let _ = bridge.send_output_settled(&sid, &mut settler, chunk); } bridge.detach_if(&sid, gen); }); diff --git a/crates/app-tauri/tests/chat_bridge.rs b/crates/app-tauri/tests/chat_bridge.rs index f774b466..1be22e08 100644 --- a/crates/app-tauri/tests/chat_bridge.rs +++ b/crates/app-tauri/tests/chat_bridge.rs @@ -197,9 +197,13 @@ fn chunk_from_event_drops_heartbeat() { /// end. This is the body of the spawned pump thread, run inline. fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec) { let mut terminal_visible = false; + let mut streamed_text = String::new(); for event in events { // Mirror the real pump: heartbeats map to no chunk and are skipped. if let Some(chunk) = chunk_from_event(event) { + if let ReplyChunk::TextDelta { text } = &chunk { + streamed_text.push_str(text); + } if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) { terminal_visible = true; } @@ -207,10 +211,12 @@ fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + describe("CustomAgentChatView", () => { beforeEach(() => { vi.clearAllMocks(); @@ -174,8 +182,136 @@ describe("CustomAgentChatView", () => { expect(await screen.findByText("Background tool")).toBeTruthy(); expect(screen.getByText("Completed away")).toBeTruthy(); expect( - screen.getByText("Reprise effectuée: 2 événements rattrapés pendant l'absence."), - ).toBeTruthy(); + screen.queryByText(/Reprise effectuée:/), + ).toBeNull(); + }); + + it("keeps the composer locked on layout return until reattach replay settles (#190)", 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", + 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: "textDelta" as const, text: "Partial answer while away" }, + ]; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + expect(screen.getByText("Initial reply")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(true); + expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true); + + act(() => { + secondReattach.resolve({ sessionId: "chat-session-1", scrollback }); + }); + + expect(await screen.findByText("Question during graph view")).toBeTruthy(); + expect(screen.getByText("Partial answer while away")).toBeTruthy(); + expect(screen.queryByText(/Reprise effectuée:/)).toBeNull(); + expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy(); + expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true); + }); + + it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => { + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [ + { kind: "userPrompt" as const, text: "resume the turn" }, + { + kind: "error" as const, + message: "Le modèle n'a renvoyé aucune réponse.", + }, + { kind: "textDelta" as const, text: "Real answer " }, + { kind: "final" as const, content: "Real answer completed" }, + ], + })), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + expect(await screen.findByText("Real answer completed")).toBeTruthy(); + expect(screen.getByText("Real answer")).toBeTruthy(); + expect(screen.queryByText("Le modèle n'a renvoyé aucune réponse.")).toBeNull(); }); it("stops the live agent with cascade feedback and releases the structured session", async () => { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 5b7d5dec..35fb17c7 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -59,6 +59,7 @@ interface CachedChatView { } const chatSessionViewCache = new Map(); +const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse."; export function __clearCustomAgentChatViewCacheForTests() { chatSessionViewCache.clear(); @@ -299,17 +300,6 @@ function appendProgress(turns: ChatTurn[], progress: ReplyProgress): ChatTurn[] return [...turns, { role: "progress", progress }]; } -function reattachNotice(missedChunks: number): ChatTurn | null { - if (missedChunks <= 0) return null; - return { - role: "tool", - label: - missedChunks === 1 - ? "Reprise effectuée: 1 événement rattrapé pendant l'absence." - : `Reprise effectuée: ${missedChunks} événements rattrapés pendant l'absence.`, - }; -} - function mergeCompletedProgress( running: ReplyProgress, completed: ReplyProgress, @@ -362,6 +352,26 @@ function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] { }); } +function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean { + return turn?.role === "error" && turn.text === EMPTY_MODEL_RESPONSE_MESSAGE; +} + +function turnsIndicateBusy(turns: ChatTurn[]): boolean { + for (let index = turns.length - 1; index >= 0; index -= 1) { + const turn = turns[index]; + if (turn.role === "final" || turn.role === "error") return false; + if (turn.role === "agent" && turn.pending) return true; + if ( + turn.role === "progress" && + !turn.settled && + progressStageBucket(turn.progress) !== "completed" + ) { + return true; + } + } + return false; +} + function fileExtension(mime: string): string { if (mime === "image/png") return "png"; if (mime === "image/jpeg") return "jpg"; @@ -490,6 +500,9 @@ function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] { raw.kind !== "userPrompt" && raw.kind !== "UserPrompt" ) { + if (isRecoverableNoResponseTurn(last) && raw.kind !== "error") { + return foldChunk(turns.slice(0, -1), raw); + } return turns; } switch (raw.kind) { @@ -556,7 +569,7 @@ export function CustomAgentChatView({ const [slashActiveIndex, setSlashActiveIndex] = useState(0); const [profileDialog, setProfileDialog] = useState(null); - const [opening, setOpening] = useState(false); + const [opening, setOpening] = useState(Boolean(sessionId)); const [busy, setBusy] = useState(false); const [stopping, setStopping] = useState(false); const [error, setError] = useState(null); @@ -793,14 +806,10 @@ export function CustomAgentChatView({ if (!mountedRef.current) return reattached.sessionId; setCurrentSession(reattached.sessionId); if (options.applyScrollback) { - const cachedCount = chatSessionViewCache.get(sid)?.chunkCount ?? 0; - scrollbackChunkCountRef.current = Math.max( - scrollbackChunkCountRef.current, - reattached.scrollback.length, - ); + scrollbackChunkCountRef.current = reattached.scrollback.length; const replayed = reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]); - const notice = reattachNotice(reattached.scrollback.length - cachedCount); - setTurns(notice ? [...replayed, notice] : replayed); + setTurns(replayed); + setBusy(turnsIndicateBusy(replayed)); } return reattached.sessionId; },