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:
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user