From e1d8f5885f9bd5f2211c97a9b55b0171863fbc8f Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 27 Jul 2026 14:30:03 +0200 Subject: [PATCH] fix(opencode): capture Final trop stricte et ordre parse/status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un tour OpenCode sans texte mais terminé proprement (step_finish, ex. tool-call-only) échouait en Decode alors qu'aucune erreur réelle ne s'était produite : parse_jsonl_turn émet désormais un Final vide dans ce cas au lieu d'un hard-fail, réservé aux flux vraiment vides, ignorés ou seulement démarrés. send() inversait aussi l'ordre de décision : un statut de sortie non nul faisait échouer la délégation même quand stdout contenait un événement terminal structuré exploitable (Final/Error). Le parse est maintenant tenté avant l'évaluation du statut, et un événement terminal structuré prévaut sur un exit code non nul. Co-Authored-By: Claude Sonnet 5 --- crates/infrastructure/src/session/opencode.rs | 155 ++++++++++++++++-- 1 file changed, 145 insertions(+), 10 deletions(-) diff --git a/crates/infrastructure/src/session/opencode.rs b/crates/infrastructure/src/session/opencode.rs index b796cd4..452cc5d 100644 --- a/crates/infrastructure/src/session/opencode.rs +++ b/crates/infrastructure/src/session/opencode.rs @@ -82,16 +82,22 @@ fn opencode_error_message(value: &Value) -> String { /// Convertit des lignes JSONL OpenCode en événements domaine. /// -/// Le `Final` est la concaténation ordonnée des événements `text`. Un stdout JSONL -/// valide mais sans texte est une erreur typée : une délégation ne peut pas être -/// considérée comme réussie sans réponse finale capturable. +/// Le `Final` est la concaténation ordonnée des événements `text`. Un tour sans +/// texte mais terminé proprement (`step_finish`) émet un `Final` vide : cela +/// couvre les tours tool-call-only tout en gardant un hard-fail pour un vrai flux +/// vide, uniquement ignoré, ou seulement démarré. 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; + let mut step_finish_seen = false; for line in lines { match parse_jsonl_event(line)? { - ParsedEvent::StepStart | ParsedEvent::StepFinish => { + ParsedEvent::StepStart => { + events.push(ReplyEvent::Heartbeat); + } + ParsedEvent::StepFinish => { + step_finish_seen = true; events.push(ReplyEvent::Heartbeat); } ParsedEvent::Text(text) => { @@ -111,6 +117,12 @@ pub fn parse_jsonl_turn(lines: &[String]) -> Result, AgentSessio if error_seen { return Ok(events); } + if step_finish_seen { + events.push(ReplyEvent::Final { + content: String::new(), + }); + return Ok(events); + } return Err(AgentSessionError::Decode( "OpenCode n'a produit aucun final textuel".to_owned(), )); @@ -121,6 +133,12 @@ pub fn parse_jsonl_turn(lines: &[String]) -> Result, AgentSessio Ok(events) } +fn has_structured_terminal_event(events: &[ReplyEvent]) -> bool { + events + .iter() + .any(|event| matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. })) +} + /// Découpe une commande utilisateur en argv sans shell implicite. /// /// Supporte les guillemets simples/doubles et les antislashs. Les expansions shell, @@ -272,20 +290,31 @@ impl AgentSession for OpenCodeSession { .await .map_err(|e| AgentSessionError::Io(e.to_string()))?; let stderr = String::from_utf8_lossy(&stderr_bytes); - if stderr.contains("server unavailable") && stderr.contains("key=idea") { - return Err(AgentSessionError::Start( - "serveur MCP OpenCode `idea` indisponible".to_owned(), - )); - } + let parsed = parse_jsonl_turn(&collected); if !status.success() { + if let Ok(events) = parsed { + if has_structured_terminal_event(&events) { + return Ok(Box::new(events.into_iter())); + } + } + if stderr.contains("server unavailable") && stderr.contains("key=idea") { + return Err(AgentSessionError::Start( + "serveur MCP OpenCode `idea` indisponible".to_owned(), + )); + } return Err(AgentSessionError::Io(format!( "OpenCode a quitté avec le statut {}: {}", status, stderr.trim() ))); } + if stderr.contains("server unavailable") && stderr.contains("key=idea") { + return Err(AgentSessionError::Start( + "serveur MCP OpenCode `idea` indisponible".to_owned(), + )); + } - let events = parse_jsonl_turn(&collected)?; + let events = parsed?; Ok(Box::new(events.into_iter())) } @@ -296,6 +325,13 @@ impl AgentSession for OpenCodeSession { #[cfg(test)] mod tests { + #[cfg(unix)] + use std::fs; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + #[cfg(unix)] + use std::path::{Path, PathBuf}; + use super::*; #[test] @@ -339,6 +375,34 @@ mod tests { assert!(matches!(err, AgentSessionError::Decode(_))); } + #[test] + fn parse_jsonl_turn_accepts_tool_only_finished_turn() { + let events = parse_jsonl_turn(&[ + r#"{"type":"step_start"}"#.to_owned(), + r#"{"type":"step_finish"}"#.to_owned(), + ]) + .unwrap(); + assert_eq!( + events, + vec![ + ReplyEvent::Heartbeat, + ReplyEvent::Heartbeat, + ReplyEvent::Final { + content: String::new() + } + ] + ); + } + + #[test] + fn parse_jsonl_turn_rejects_truly_empty_or_ignored_turn() { + let empty = parse_jsonl_turn(&[]).unwrap_err(); + assert!(matches!(empty, AgentSessionError::Decode(_))); + let ignored = + parse_jsonl_turn(&[r#"{"type":"session","id":"s1"}"#.to_owned()]).unwrap_err(); + assert!(matches!(ignored, AgentSessionError::Decode(_))); + } + #[test] fn parse_jsonl_event_rejects_invalid_json() { let err = parse_jsonl_event("{nope").unwrap_err(); @@ -399,4 +463,75 @@ mod tests { let event = parse_jsonl_event(r#"{"type":"error","error":"panne réseau"}"#).unwrap(); assert_eq!(event, ParsedEvent::Error("panne réseau".to_owned())); } + + #[cfg(unix)] + struct TempDir(PathBuf); + + #[cfg(unix)] + impl TempDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir() + .join(format!("idea-opencode-{label}-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + #[cfg(unix)] + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn send_prefers_parseable_stdout_over_nonzero_exit_status() { + let tmp = TempDir::new("exit-one"); + let script = tmp.path().join("opencode-fixture.sh"); + fs::write( + &script, + r#"#!/bin/sh +printf '%s\n' '{"type":"step_start"}' +printf '%s\n' '{"type":"text","part":{"text":"résultat exploitable"}}' +printf '%s\n' '{"type":"step_finish"}' +printf '%s\n' 'stderr générique' >&2 +exit 1 +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(&script).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&script, permissions).unwrap(); + let session = OpenCodeSession::new( + SessionId::new_random(), + script.to_string_lossy(), + Vec::new(), + tmp.path().to_string_lossy(), + Vec::new(), + None, + None, + ) + .unwrap(); + + let events = session.send("prompt").await.unwrap().collect::>(); + + assert_eq!( + events, + vec![ + ReplyEvent::Heartbeat, + ReplyEvent::TextDelta { + text: "résultat exploitable".to_owned() + }, + ReplyEvent::Heartbeat, + ReplyEvent::Final { + content: "résultat exploitable".to_owned() + } + ] + ); + } }