diff --git a/crates/infrastructure/src/session/codex.rs b/crates/infrastructure/src/session/codex.rs index 7cbea48..679b49b 100644 --- a/crates/infrastructure/src/session/codex.rs +++ b/crates/infrastructure/src/session/codex.rs @@ -421,7 +421,7 @@ impl AgentSession for CodexExecSession { } async fn send(&self, prompt: &str) -> Result { - self.send_inner(prompt, None, true).await + self.send_inner(prompt, None).await } async fn send_with_tap( @@ -429,7 +429,7 @@ impl AgentSession for CodexExecSession { prompt: &str, tap: std::sync::mpsc::Sender, ) -> Result { - self.send_inner(prompt, Some(tap), false).await + self.send_inner(prompt, Some(tap)).await } async fn shutdown(&self) -> Result<(), AgentSessionError> { @@ -443,17 +443,17 @@ impl CodexExecSession { &self, prompt: &str, tap: Option>, - include_stream_announcements: bool, ) -> Result { let spec = self.build_spawn_line(prompt); + let has_tap = tap.is_some(); let (line_tap, parser_thread) = tap.map_or((None, None), |event_tap| { let (line_tx, line_rx) = std::sync::mpsc::channel::(); let parser = std::thread::spawn(move || { for line in line_rx { if let Ok(parsed) = parse_event(&line) { for event in parsed.events { - if let ReplyEvent::Final { content } = event { - let _ = event_tap.send(ReplyEvent::Announcement { text: content }); + if !matches!(event, ReplyEvent::Final { .. }) { + let _ = event_tap.send(event); } } } @@ -488,7 +488,7 @@ impl CodexExecSession { // Un `agent_message` précédemment retenu est supersédé : il devient // une annonce non terminale, on garde le plus récent en conclusion. if last_final.is_some() { - if include_stream_announcements { + if !has_tap { events.push(ReplyEvent::Announcement { text: last_final.take().expect("présent"), }); @@ -496,7 +496,11 @@ impl CodexExecSession { } last_final = Some(content); } - other => events.push(other), + other => { + if !has_tap { + events.push(other); + } + } } } } diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index a1a481b..90e335c 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -1216,10 +1216,12 @@ mod tests { } #[tokio::test] - async fn codex_send_with_tap_emits_each_agent_message_live_as_announcement() { + async fn codex_send_with_tap_emits_provider_events_live_before_final_without_replay() { let fake = FakeCli::printing(&[ r#"{"type":"thread.started","thread_id":"c"}"#, - r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"je regarde"}}"#, + r#"{"type":"turn.started"}"#, + r#"{"type":"item.completed","item":{"id":"tool0","type":"command","text":"cargo test"}}"#, + r#"{"type":"turn.completed"}"#, r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"résultat"}}"#, ]); let s = CodexExecSession::new( @@ -1237,24 +1239,31 @@ mod tests { let events: Vec<_> = s.send_with_tap("x", tx).await.expect("send").collect(); let live: Vec<_> = rx.into_iter().collect(); - assert_eq!( - live, - vec![ - ReplyEvent::Announcement { - text: "je regarde".into() - }, - ReplyEvent::Announcement { - text: "résultat".into() - }, - ], - "le tap live publie chaque agent_message, y compris celui qui deviendra Final" + assert!( + matches!(live.first(), Some(ReplyEvent::Progress { progress }) if progress.native_event.as_deref() == Some("turn.started")), + "le démarrage du tour provider doit être tapé live en premier" + ); + assert!( + live.iter().any( + |event| matches!(event, ReplyEvent::ToolActivity { label } if label == "command") + ), + "l'activité outil provider doit être tapée live" + ); + assert!( + live.iter().any(|event| matches!(event, ReplyEvent::Progress { progress } if progress.native_event.as_deref() == Some("turn.completed"))), + "la fin de tour provider doit être tapée live avant le Final" + ); + assert!( + live.iter() + .all(|event| !matches!(event, ReplyEvent::Final { .. })), + "le Final reste réservé au flux autoritaire" ); assert_eq!( without_progress(events.clone()), vec![ReplyEvent::Final { content: "résultat".into() }], - "le flux final du chemin tap garde seulement le dernier Final pour le demandeur" + "le flux final du chemin tap ne rejoue pas les événements déjà projetés live" ); } diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 97a4ba7..fecd2df 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -37,7 +37,17 @@ function addPluginCommand( pluginId, displayName: "Acme Plugin", contributes: { - commands: [{ id: commandId, title: "Explain", shortDescription: "Explain selection" }], + menus: [], + menuItems: [], + slashCommands: [ + { + name: "/explain", + shortDescription: "Explain selection", + command: commandId, + }, + ], + layouts: [], + mcpServers: [], }, commands, layouts: new PluginLayoutRegistry(pluginId, new Set()), @@ -408,6 +418,83 @@ describe("CustomAgentChatView", () => { expect(screen.getByText("Progress").parentElement?.textContent).toContain("done"); }); + it("does not append late progress chunks after the final answer", async () => { + let emitChunk: ((chunk: unknown) => void) | null = null; + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn( + (_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) => + new Promise((resolve) => { + emitChunk = (chunk: unknown) => { + onChunk(chunk); + if ((chunk as { kind?: string }).kind === "final") resolve(); + }; + }), + ), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + await waitFor(() => + expect(agent.reattachAgentChat).toHaveBeenCalledWith( + "chat-session-1", + expect.any(Function), + ), + ); + + fireEvent.change(screen.getByLabelText(/message CLI custom/), { + target: { value: "ship ordered stream" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + await waitFor(() => expect(emitChunk).not.toBeNull()); + + act(() => { + emitChunk?.({ kind: "final", content: "done" }); + }); + await screen.findByText("done"); + + act(() => { + emitChunk?.({ + kind: "progress", + progress: { + source: "ideaLocal", + kind: "mcp", + stage: "started", + label: "idea_ticket_read", + toolName: "idea_ticket_read", + }, + }); + }); + + expect(screen.queryByText("idea_ticket_read")).toBeNull(); + }); + it("shows slash-command suggestions from the gateway, filters by prefix, and inserts the selected command", async () => { const agent = { launchAgentChat: vi.fn(), @@ -1040,6 +1127,7 @@ describe("CustomAgentChatView", () => { expect(shell.className).toContain("overflow-hidden"); expect(toolbar.className).toContain("overflow-hidden"); expect(cancel.className).toContain("shrink-0"); + expect(cancel.className).toContain("z-30"); expect(scroll.className).toContain("flex-1"); expect(scroll.className).toContain("basis-0"); expect(scroll.className).toContain("overflow-y-auto"); diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index c5d7797..e1de0aa 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -299,6 +299,14 @@ function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] { if (!isReplyRecord(raw)) { return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }]; } + const last = turns[turns.length - 1]; + if ( + (last?.role === "final" || last?.role === "error") && + raw.kind !== "userPrompt" && + raw.kind !== "UserPrompt" + ) { + return turns; + } switch (raw.kind) { case "textDelta": return appendAgentDelta(turns, String(raw.text ?? "")); @@ -946,7 +954,7 @@ export function CustomAgentChatView({
{agentName}
@@ -959,7 +967,7 @@ export function CustomAgentChatView({