fix(opencode): le Final textuel exploitable survit au changement de messageID du tour — sélection du message courant par priorité (texte non vide > erreur tardive > step/tool) élimine les NoReply sporadiques sur rendez-vous structurés — #254 (QA verte)

This commit is contained in:
2026-08-24 12:21:02 +02:00
parent 7f445cbd6d
commit b6238f002e

View File

@ -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<String> {
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(