diff --git a/crates/infrastructure/src/session/opencode.rs b/crates/infrastructure/src/session/opencode.rs index ded8e91..1b4e405 100644 --- a/crates/infrastructure/src/session/opencode.rs +++ b/crates/infrastructure/src/session/opencode.rs @@ -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 { 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 { pub fn parse_jsonl_turn(lines: &[String]) -> Result, 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, 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 = 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())); + } }