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:
@ -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(
|
||||
|
||||
Reference in New Issue
Block a user