fix(opencode): capture Final trop stricte et ordre parse/status

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 14:30:03 +02:00
parent 6f532d7434
commit e1d8f5885f

View File

@ -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<Vec<ReplyEvent>, 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<Vec<ReplyEvent>, 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<Vec<ReplyEvent>, 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::<Vec<_>>();
assert_eq!(
events,
vec![
ReplyEvent::Heartbeat,
ReplyEvent::TextDelta {
text: "résultat exploitable".to_owned()
},
ReplyEvent::Heartbeat,
ReplyEvent::Final {
content: "résultat exploitable".to_owned()
}
]
);
}
}