merge(session): finalisation Codex — réponse non finalisée sur session Codex — #240 (QA verte)

This commit is contained in:
2026-08-22 17:27:21 +02:00
4 changed files with 75 additions and 11 deletions

View File

@ -699,17 +699,21 @@ impl CodexExecSession {
} }
fn event_supersedes_pending_final(event: &ReplyEvent) -> bool { fn event_supersedes_pending_final(event: &ReplyEvent) -> bool {
!matches!( match event {
event, ReplyEvent::Progress { progress } => {
ReplyEvent::Heartbeat progress.kind != ReplyProgressKind::Turn
| ReplyEvent::RateLimited { .. } && matches!(
| ReplyEvent::Progress { progress.stage,
progress: ReplyProgress { ReplyProgressStage::Started | ReplyProgressStage::Delta
kind: ReplyProgressKind::Turn,
..
}
}
) )
}
ReplyEvent::ToolActivity { .. } | ReplyEvent::Error { .. } => true,
ReplyEvent::Final { .. }
| ReplyEvent::TextDelta { .. }
| ReplyEvent::Announcement { .. }
| ReplyEvent::Heartbeat
| ReplyEvent::RateLimited { .. } => false,
}
} }
fn agent_message_progress_from_line(line: &str) -> Result<Option<ReplyEvent>, AgentSessionError> { fn agent_message_progress_from_line(line: &str) -> Result<Option<ReplyEvent>, AgentSessionError> {

View File

@ -1385,6 +1385,41 @@ mod tests {
); );
} }
#[tokio::test]
async fn codex_agent_message_survives_late_completed_item_as_final() {
let fake = FakeCli::printing(&[
r#"{"type":"thread.started","thread_id":"c"}"#,
r#"{"type":"turn.started"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"réponse exploitable"}}"#,
r#"{"type":"item.completed","item":{"id":"i1","type":"reasoning","text":"post-traitement"}}"#,
r#"{"type":"turn.completed","usage":{}}"#,
]);
let s = CodexExecSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
Vec::new(),
Vec::new(),
None,
None,
);
let events: Vec<_> = s.send("x").await.expect("send").collect();
let finals: Vec<_> = events
.iter()
.filter_map(|event| match event {
ReplyEvent::Final { content } => Some(content.as_str()),
_ => None,
})
.collect();
assert_eq!(
finals,
vec!["réponse exploitable"],
"a late completed Codex item must not erase the last agent_message"
);
}
#[tokio::test] #[tokio::test]
async fn codex_zero_agent_message_has_no_final() { async fn codex_zero_agent_message_has_no_final() {
let fake = FakeCli::printing(&[ let fake = FakeCli::printing(&[

View File

@ -383,6 +383,7 @@ describe("CustomAgentChatView", () => {
expect(agentBubble).toBeTruthy(); expect(agentBubble).toBeTruthy();
expect(errorBubble).toBeTruthy(); expect(errorBubble).toBeTruthy();
expect(within(errorBubble!).getByText("Session Codex")).toBeTruthy();
expect( expect(
within(agentBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)), within(agentBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)),
).toBeTruthy(); ).toBeTruthy();

View File

@ -144,6 +144,23 @@ function describeOpenFailure(e: unknown): string {
return message || "Cause technique non disponible."; return message || "Cause technique non disponible.";
} }
function structuredSessionLabel(
adapter: AgentProfile["structuredAdapter"],
): string | null {
switch (adapter) {
case "codex":
return "Session Codex";
case "claude":
return "Session Claude";
case "openCode":
return "Session OpenCode";
case "openAiCompatible":
return "Session HTTP";
default:
return null;
}
}
function instrumentationError(e: unknown): Record<string, unknown> { function instrumentationError(e: unknown): Record<string, unknown> {
if (e && typeof e === "object") { if (e && typeof e === "object") {
const err = e as GatewayError & { name?: string }; const err = e as GatewayError & { name?: string };
@ -1018,6 +1035,7 @@ export function CustomAgentChatView({
[agentId, conversationId, projectId], [agentId, conversationId, projectId],
); );
const displayTurns = turns; const displayTurns = turns;
const sessionLabel = structuredSessionLabel(profile.structuredAdapter);
const [requesterDelegation, setRequesterDelegation] = const [requesterDelegation, setRequesterDelegation] =
useState<RequesterDelegationState>({ active: false }); useState<RequesterDelegationState>({ active: false });
@ -2162,6 +2180,7 @@ export function CustomAgentChatView({
key={index} key={index}
turn={turn} turn={turn}
requesterName={agentName} requesterName={agentName}
sessionLabel={sessionLabel}
expandedProgressKeys={expandedProgressKeys} expandedProgressKeys={expandedProgressKeys}
onToggleProgressExpanded={(key) => onToggleProgressExpanded={(key) =>
setExpandedProgressKeys((prev) => { setExpandedProgressKeys((prev) => {
@ -2352,11 +2371,13 @@ export function CustomAgentChatView({
function ChatBubble({ function ChatBubble({
turn, turn,
requesterName, requesterName,
sessionLabel,
expandedProgressKeys, expandedProgressKeys,
onToggleProgressExpanded, onToggleProgressExpanded,
}: { }: {
turn: ChatTurn; turn: ChatTurn;
requesterName: string; requesterName: string;
sessionLabel: string | null;
expandedProgressKeys: Set<string>; expandedProgressKeys: Set<string>;
onToggleProgressExpanded: (key: string) => void; onToggleProgressExpanded: (key: string) => void;
}) { }) {
@ -2406,6 +2427,9 @@ function ChatBubble({
labelClassName="font-semibold text-danger" labelClassName="font-semibold text-danger"
/> />
)} )}
{turn.role === "error" && sessionLabel && (
<p className="mb-1 font-medium text-danger/85">{sessionLabel}</p>
)}
{turn.role === "unknown" ? "Chunk inconnu reçu: " : ""} {turn.role === "unknown" ? "Chunk inconnu reçu: " : ""}
<span className="whitespace-pre-wrap break-words">{turn.text}</span> <span className="whitespace-pre-wrap break-words">{turn.text}</span>
</div> </div>