fix(cli): z-order Cancel #151 + projection live événements avant Final #167 (+ alignement fixture plugin #165/#166) — QA verte

This commit is contained in:
2026-08-06 19:09:07 +02:00
parent 3d4ea6859e
commit b45deabe6d
4 changed files with 133 additions and 24 deletions

View File

@ -421,7 +421,7 @@ impl AgentSession for CodexExecSession {
} }
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> { async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
self.send_inner(prompt, None, true).await self.send_inner(prompt, None).await
} }
async fn send_with_tap( async fn send_with_tap(
@ -429,7 +429,7 @@ impl AgentSession for CodexExecSession {
prompt: &str, prompt: &str,
tap: std::sync::mpsc::Sender<ReplyEvent>, tap: std::sync::mpsc::Sender<ReplyEvent>,
) -> Result<ReplyStream, AgentSessionError> { ) -> Result<ReplyStream, AgentSessionError> {
self.send_inner(prompt, Some(tap), false).await self.send_inner(prompt, Some(tap)).await
} }
async fn shutdown(&self) -> Result<(), AgentSessionError> { async fn shutdown(&self) -> Result<(), AgentSessionError> {
@ -443,17 +443,17 @@ impl CodexExecSession {
&self, &self,
prompt: &str, prompt: &str,
tap: Option<std::sync::mpsc::Sender<ReplyEvent>>, tap: Option<std::sync::mpsc::Sender<ReplyEvent>>,
include_stream_announcements: bool,
) -> Result<ReplyStream, AgentSessionError> { ) -> Result<ReplyStream, AgentSessionError> {
let spec = self.build_spawn_line(prompt); 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_tap, parser_thread) = tap.map_or((None, None), |event_tap| {
let (line_tx, line_rx) = std::sync::mpsc::channel::<String>(); let (line_tx, line_rx) = std::sync::mpsc::channel::<String>();
let parser = std::thread::spawn(move || { let parser = std::thread::spawn(move || {
for line in line_rx { for line in line_rx {
if let Ok(parsed) = parse_event(&line) { if let Ok(parsed) = parse_event(&line) {
for event in parsed.events { for event in parsed.events {
if let ReplyEvent::Final { content } = event { if !matches!(event, ReplyEvent::Final { .. }) {
let _ = event_tap.send(ReplyEvent::Announcement { text: content }); let _ = event_tap.send(event);
} }
} }
} }
@ -488,7 +488,7 @@ impl CodexExecSession {
// Un `agent_message` précédemment retenu est supersédé : il devient // Un `agent_message` précédemment retenu est supersédé : il devient
// une annonce non terminale, on garde le plus récent en conclusion. // une annonce non terminale, on garde le plus récent en conclusion.
if last_final.is_some() { if last_final.is_some() {
if include_stream_announcements { if !has_tap {
events.push(ReplyEvent::Announcement { events.push(ReplyEvent::Announcement {
text: last_final.take().expect("présent"), text: last_final.take().expect("présent"),
}); });
@ -496,7 +496,11 @@ impl CodexExecSession {
} }
last_final = Some(content); last_final = Some(content);
} }
other => events.push(other), other => {
if !has_tap {
events.push(other);
}
}
} }
} }
} }

View File

@ -1216,10 +1216,12 @@ mod tests {
} }
#[tokio::test] #[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(&[ let fake = FakeCli::printing(&[
r#"{"type":"thread.started","thread_id":"c"}"#, 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"}}"#, r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"résultat"}}"#,
]); ]);
let s = CodexExecSession::new( let s = CodexExecSession::new(
@ -1237,24 +1239,31 @@ mod tests {
let events: Vec<_> = s.send_with_tap("x", tx).await.expect("send").collect(); let events: Vec<_> = s.send_with_tap("x", tx).await.expect("send").collect();
let live: Vec<_> = rx.into_iter().collect(); let live: Vec<_> = rx.into_iter().collect();
assert_eq!( assert!(
live, matches!(live.first(), Some(ReplyEvent::Progress { progress }) if progress.native_event.as_deref() == Some("turn.started")),
vec![ "le démarrage du tour provider doit être tapé live en premier"
ReplyEvent::Announcement { );
text: "je regarde".into() assert!(
}, live.iter().any(
ReplyEvent::Announcement { |event| matches!(event, ReplyEvent::ToolActivity { label } if label == "command")
text: "résultat".into() ),
}, "l'activité outil provider doit être tapée live"
], );
"le tap live publie chaque agent_message, y compris celui qui deviendra Final" 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!( assert_eq!(
without_progress(events.clone()), without_progress(events.clone()),
vec![ReplyEvent::Final { vec![ReplyEvent::Final {
content: "résultat".into() 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"
); );
} }

View File

@ -37,7 +37,17 @@ function addPluginCommand(
pluginId, pluginId,
displayName: "Acme Plugin", displayName: "Acme Plugin",
contributes: { contributes: {
commands: [{ id: commandId, title: "Explain", shortDescription: "Explain selection" }], menus: [],
menuItems: [],
slashCommands: [
{
name: "/explain",
shortDescription: "Explain selection",
command: commandId,
},
],
layouts: [],
mcpServers: [],
}, },
commands, commands,
layouts: new PluginLayoutRegistry(pluginId, new Set()), layouts: new PluginLayoutRegistry(pluginId, new Set()),
@ -408,6 +418,83 @@ describe("CustomAgentChatView", () => {
expect(screen.getByText("Progress").parentElement?.textContent).toContain("done"); 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<void>((resolve) => {
emitChunk = (chunk: unknown) => {
onChunk(chunk);
if ((chunk as { kind?: string }).kind === "final") resolve();
};
}),
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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 () => { it("shows slash-command suggestions from the gateway, filters by prefix, and inserts the selected command", async () => {
const agent = { const agent = {
launchAgentChat: vi.fn(), launchAgentChat: vi.fn(),
@ -1040,6 +1127,7 @@ describe("CustomAgentChatView", () => {
expect(shell.className).toContain("overflow-hidden"); expect(shell.className).toContain("overflow-hidden");
expect(toolbar.className).toContain("overflow-hidden"); expect(toolbar.className).toContain("overflow-hidden");
expect(cancel.className).toContain("shrink-0"); expect(cancel.className).toContain("shrink-0");
expect(cancel.className).toContain("z-30");
expect(scroll.className).toContain("flex-1"); expect(scroll.className).toContain("flex-1");
expect(scroll.className).toContain("basis-0"); expect(scroll.className).toContain("basis-0");
expect(scroll.className).toContain("overflow-y-auto"); expect(scroll.className).toContain("overflow-y-auto");

View File

@ -299,6 +299,14 @@ function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
if (!isReplyRecord(raw)) { if (!isReplyRecord(raw)) {
return [...turns, { role: "unknown", text: unknownChunkLabel(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) { switch (raw.kind) {
case "textDelta": case "textDelta":
return appendAgentDelta(turns, String(raw.text ?? "")); return appendAgentDelta(turns, String(raw.text ?? ""));
@ -946,7 +954,7 @@ export function CustomAgentChatView({
<div <div
role="toolbar" role="toolbar"
aria-label="custom agent chat actions" aria-label="custom agent chat actions"
className="flex shrink-0 items-center gap-2 overflow-hidden border-b border-border px-3 py-2" className="relative z-20 flex shrink-0 items-center gap-2 overflow-hidden border-b border-border px-3 py-2"
> >
<div className="min-w-0 flex-1 overflow-hidden"> <div className="min-w-0 flex-1 overflow-hidden">
<div className="truncate text-sm font-medium">{agentName}</div> <div className="truncate text-sm font-medium">{agentName}</div>
@ -959,7 +967,7 @@ export function CustomAgentChatView({
<Button <Button
size="sm" size="sm"
variant="danger" variant="danger"
className="shrink-0 whitespace-nowrap" className="relative z-30 shrink-0 whitespace-nowrap shadow-sm"
onClick={() => void cancel()} onClick={() => void cancel()}
> >
Cancel Cancel