merge feature/fix-opencode-final-capture dans develop
This commit is contained in:
@ -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()
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user