Merge feature/fix-opencode-jsonl-parsing into develop

Fix confiné à l'adaptateur OpenCode (parsing JSONL text.part.text +
gestion événements error), validé par QA (cargo test -p infrastructure
309 passed, 0 failed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 16:23:59 +02:00

View File

@ -26,10 +26,15 @@ pub enum ParsedEvent {
Text(String),
/// Fin d'étape OpenCode.
StepFinish,
/// Erreur terminale remontée par OpenCode.
Error(String),
/// Evénement JSON valide mais hors contrat minimal.
Ignored,
}
/// Message d'erreur par défaut quand `error` n'est ni une chaîne ni un objet exploitable.
const OPENCODE_ERROR_FALLBACK: &str = "OpenCode a signalé une erreur sans détail exploitable";
/// Parse une ligne JSONL OpenCode.
///
/// # Errors
@ -44,18 +49,37 @@ pub fn parse_jsonl_event(line: &str) -> Result<ParsedEvent, AgentSessionError> {
match value.get("type").and_then(Value::as_str) {
Some("step_start") | Some("step.start") => Ok(ParsedEvent::StepStart),
Some("step_finish") | Some("step.finish") => Ok(ParsedEvent::StepFinish),
// Le texte réel est niché sous `.part.text` (cf. `packages/opencode/src/cli/cmd/run.ts`) ;
// un seul événement `text` par part, déjà finalisé — pas de delta à dédupliquer.
Some("text") => Ok(ParsedEvent::Text(
value
.get("text")
.or_else(|| value.get("content"))
.get("part")
.and_then(|part| part.get("text"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
)),
Some("error") => Ok(ParsedEvent::Error(opencode_error_message(&value))),
_ => Ok(ParsedEvent::Ignored),
}
}
/// Extrait un message d'erreur exploitable depuis `value.error`, qu'il s'agisse d'une
/// chaîne brute ou d'un objet `{message: ...}` (forme exacte non confirmée empiriquement).
fn opencode_error_message(value: &Value) -> String {
let error = value.get("error");
error
.and_then(Value::as_str)
.map(str::to_owned)
.or_else(|| {
error
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
.map(str::to_owned)
})
.unwrap_or_else(|| OPENCODE_ERROR_FALLBACK.to_owned())
}
/// Convertit des lignes JSONL OpenCode en événements domaine.
///
/// Le `Final` est la concaténation ordonnée des événements `text`. Un stdout JSONL
@ -64,6 +88,7 @@ pub fn parse_jsonl_event(line: &str) -> Result<ParsedEvent, AgentSessionError> {
pub fn parse_jsonl_turn(lines: &[String]) -> Result<Vec<ReplyEvent>, AgentSessionError> {
let mut events = Vec::new();
let mut final_text = String::new();
let mut error_seen = false;
for line in lines {
match parse_jsonl_event(line)? {
ParsedEvent::StepStart | ParsedEvent::StepFinish => {
@ -75,10 +100,17 @@ pub fn parse_jsonl_turn(lines: &[String]) -> Result<Vec<ReplyEvent>, AgentSessio
events.push(ReplyEvent::TextDelta { text });
}
}
ParsedEvent::Error(message) => {
error_seen = true;
events.push(ReplyEvent::Error { message });
}
ParsedEvent::Ignored => {}
}
}
if final_text.trim().is_empty() {
if error_seen {
return Ok(events);
}
return Err(AgentSessionError::Decode(
"OpenCode n'a produit aucun final textuel".to_owned(),
));
@ -278,8 +310,8 @@ mod tests {
fn parse_jsonl_turn_concatenates_text_and_adds_final() {
let events = parse_jsonl_turn(&[
r#"{"type":"step_start"}"#.to_owned(),
r#"{"type":"text","text":"hel"}"#.to_owned(),
r#"{"type":"text","text":"lo"}"#.to_owned(),
r#"{"type":"text","part":{"text":"hel"}}"#.to_owned(),
r#"{"type":"text","part":{"text":"lo"}}"#.to_owned(),
r#"{"type":"step_finish"}"#.to_owned(),
])
.unwrap();
@ -312,4 +344,60 @@ mod tests {
let err = parse_jsonl_event("{nope").unwrap_err();
assert!(matches!(err, AgentSessionError::Decode(_)));
}
/// Format JSONL réel de `opencode run --format json`, confirmé par lecture du
/// source `packages/opencode/src/cli/cmd/run.ts` : le texte est niché sous
/// `.part.text`, pas à la racine.
fn opencode_script() -> Vec<&'static str> {
vec![
r#"{"type":"step_start","timestamp":1,"sessionID":"s1"}"#,
r#"{"type":"text","timestamp":2,"sessionID":"s1","part":{"id":"p1","type":"text","text":"réponse réelle","time":{"start":1,"end":2}}}"#,
r#"{"type":"step_finish","timestamp":3,"sessionID":"s1"}"#,
]
}
#[test]
fn parse_jsonl_turn_reads_text_nested_under_part() {
let lines: Vec<String> = opencode_script().into_iter().map(str::to_owned).collect();
let events = parse_jsonl_turn(&lines).unwrap();
assert_eq!(
events,
vec![
ReplyEvent::Heartbeat,
ReplyEvent::TextDelta {
text: "réponse réelle".to_owned()
},
ReplyEvent::Heartbeat,
ReplyEvent::Final {
content: "réponse réelle".to_owned()
}
]
);
}
#[test]
fn parse_jsonl_turn_emits_error_event() {
let events = parse_jsonl_turn(&[
r#"{"type":"step_start","timestamp":1,"sessionID":"s1"}"#.to_owned(),
r#"{"type":"error","timestamp":2,"sessionID":"s1","error":{"message":"quelque chose a échoué"}}"#
.to_owned(),
])
.unwrap();
assert_eq!(
events,
vec![
ReplyEvent::Heartbeat,
ReplyEvent::Error {
message: "quelque chose a échoué".to_owned()
}
]
);
}
#[test]
fn parse_jsonl_event_error_accepts_raw_string() {
let event =
parse_jsonl_event(r#"{"type":"error","error":"panne réseau"}"#).unwrap();
assert_eq!(event, ParsedEvent::Error("panne réseau".to_owned()));
}
}