From b6238f002e593a3e8105305a3af0705d85014207 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 24 Aug 2026 12:21:02 +0200 Subject: [PATCH] =?UTF-8?q?fix(opencode):=20le=20Final=20textuel=20exploit?= =?UTF-8?q?able=20survit=20au=20changement=20de=20messageID=20du=20tour=20?= =?UTF-8?q?=E2=80=94=20s=C3=A9lection=20du=20message=20courant=20par=20pri?= =?UTF-8?q?orit=C3=A9=20(texte=20non=20vide=20>=20erreur=20tardive=20>=20s?= =?UTF-8?q?tep/tool)=20=C3=A9limine=20les=20NoReply=20sporadiques=20sur=20?= =?UTF-8?q?rendez-vous=20structur=C3=A9s=20=E2=80=94=20#254=20(QA=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/infrastructure/src/session/opencode.rs | 90 ++++++++++++++++--- 1 file changed, 77 insertions(+), 13 deletions(-) diff --git a/crates/infrastructure/src/session/opencode.rs b/crates/infrastructure/src/session/opencode.rs index 01679468..d4bbeb67 100644 --- a/crates/infrastructure/src/session/opencode.rs +++ b/crates/infrastructure/src/session/opencode.rs @@ -438,19 +438,7 @@ fn current_turn_records( records.push(record); } } - let Some(current_message_id) = records.iter().rev().find_map(|record| { - matches!( - record.event, - ParsedEvent::Text(_) - | ParsedEvent::ToolActivity { .. } - | ParsedEvent::StepStart - | ParsedEvent::StepFinish - | ParsedEvent::Error(_) - ) - .then(|| record.message_id.as_deref()) - .flatten() - .map(str::to_owned) - }) else { + let Some(current_message_id) = current_message_id_from_records(&records) else { return Ok(records); }; Ok(records @@ -464,6 +452,38 @@ fn current_turn_records( .collect()) } +fn current_message_id_from_records(records: &[ParsedRecord]) -> Option { + let latest_text = records + .iter() + .enumerate() + .rev() + .find(|(_, record)| exploitable_text_record(record)); + let latest_error = records + .iter() + .enumerate() + .rev() + .find(|(_, record)| matches!(record.event, ParsedEvent::Error(_))); + match (latest_text, latest_error) { + (Some((text_index, _)), Some((error_index, error))) if error_index > text_index => { + error.message_id.clone() + } + (Some((_, text)), _) => text.message_id.clone(), + (None, Some((_, error))) => error.message_id.clone(), + (None, None) => records.iter().rev().find_map(|record| { + matches!( + record.event, + ParsedEvent::ToolActivity { .. } | ParsedEvent::StepStart | ParsedEvent::StepFinish + ) + .then(|| record.message_id.clone()) + .flatten() + }), + } +} + +fn exploitable_text_record(record: &ParsedRecord) -> bool { + matches!(&record.event, ParsedEvent::Text(text) if !text.trim().is_empty()) +} + fn has_structured_terminal_event(events: &[ReplyEvent]) -> bool { events .iter() @@ -991,6 +1011,50 @@ mod tests { ); } + #[test] + fn parse_jsonl_turn_keeps_assistant_text_when_terminal_event_uses_different_message_id() { + let events = parse_jsonl_turn(&[ + r#"{"type":"step_start","sessionID":"s1","part":{"messageID":"a","type":"step-start"}}"# + .to_owned(), + r#"{"type":"text","sessionID":"s1","part":{"messageID":"a","type":"text","text":"réponse utile"}}"# + .to_owned(), + r#"{"type":"step_finish","sessionID":"s1","part":{"messageID":"b","type":"step-finish"}}"# + .to_owned(), + ]) + .unwrap(); + + assert_eq!( + events.last(), + Some(&ReplyEvent::Final { + content: "réponse utile".to_owned() + }) + ); + } + + #[test] + fn parse_jsonl_turn_keeps_later_error_instead_of_promoting_earlier_text_to_final() { + let events = parse_jsonl_turn(&[ + r#"{"type":"text","sessionID":"s1","part":{"messageID":"a","type":"text","text":"réponse partielle"}}"# + .to_owned(), + r#"{"type":"error","sessionID":"s1","part":{"messageID":"b"},"error":"provider failed"}"# + .to_owned(), + ]) + .unwrap(); + + assert!( + events + .iter() + .any(|event| matches!(event, ReplyEvent::Error { message } if message == "provider failed")), + "a later provider error must stay visible: {events:?}" + ); + assert!( + !events + .iter() + .any(|event| matches!(event, ReplyEvent::Final { .. })), + "a provider error must not be masked as a successful final: {events:?}" + ); + } + #[test] fn parse_jsonl_turn_scoped_ignores_other_sessions_when_known() { let events = parse_jsonl_turn_scoped(