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(