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.
|
/// Convertit des lignes JSONL OpenCode en événements domaine.
|
||||||
///
|
///
|
||||||
/// Le `Final` est la concaténation ordonnée des événements `text`. Un stdout JSONL
|
/// Le `Final` est la concaténation ordonnée des événements `text`. Un tour sans
|
||||||
/// valide mais sans texte est une erreur typée : une délégation ne peut pas être
|
/// texte mais terminé proprement (`step_finish`) émet un `Final` vide : cela
|
||||||
/// considérée comme réussie sans réponse finale capturable.
|
/// 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> {
|
pub fn parse_jsonl_turn(lines: &[String]) -> Result<Vec<ReplyEvent>, AgentSessionError> {
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let mut final_text = String::new();
|
let mut final_text = String::new();
|
||||||
let mut error_seen = false;
|
let mut error_seen = false;
|
||||||
|
let mut step_finish_seen = false;
|
||||||
for line in lines {
|
for line in lines {
|
||||||
match parse_jsonl_event(line)? {
|
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);
|
events.push(ReplyEvent::Heartbeat);
|
||||||
}
|
}
|
||||||
ParsedEvent::Text(text) => {
|
ParsedEvent::Text(text) => {
|
||||||
@ -111,6 +117,12 @@ pub fn parse_jsonl_turn(lines: &[String]) -> Result<Vec<ReplyEvent>, AgentSessio
|
|||||||
if error_seen {
|
if error_seen {
|
||||||
return Ok(events);
|
return Ok(events);
|
||||||
}
|
}
|
||||||
|
if step_finish_seen {
|
||||||
|
events.push(ReplyEvent::Final {
|
||||||
|
content: String::new(),
|
||||||
|
});
|
||||||
|
return Ok(events);
|
||||||
|
}
|
||||||
return Err(AgentSessionError::Decode(
|
return Err(AgentSessionError::Decode(
|
||||||
"OpenCode n'a produit aucun final textuel".to_owned(),
|
"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)
|
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.
|
/// Découpe une commande utilisateur en argv sans shell implicite.
|
||||||
///
|
///
|
||||||
/// Supporte les guillemets simples/doubles et les antislashs. Les expansions shell,
|
/// Supporte les guillemets simples/doubles et les antislashs. Les expansions shell,
|
||||||
@ -272,20 +290,31 @@ impl AgentSession for OpenCodeSession {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| AgentSessionError::Io(e.to_string()))?;
|
.map_err(|e| AgentSessionError::Io(e.to_string()))?;
|
||||||
let stderr = String::from_utf8_lossy(&stderr_bytes);
|
let stderr = String::from_utf8_lossy(&stderr_bytes);
|
||||||
if stderr.contains("server unavailable") && stderr.contains("key=idea") {
|
let parsed = parse_jsonl_turn(&collected);
|
||||||
return Err(AgentSessionError::Start(
|
|
||||||
"serveur MCP OpenCode `idea` indisponible".to_owned(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !status.success() {
|
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!(
|
return Err(AgentSessionError::Io(format!(
|
||||||
"OpenCode a quitté avec le statut {}: {}",
|
"OpenCode a quitté avec le statut {}: {}",
|
||||||
status,
|
status,
|
||||||
stderr.trim()
|
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()))
|
Ok(Box::new(events.into_iter()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -296,6 +325,13 @@ impl AgentSession for OpenCodeSession {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::fs;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -339,6 +375,34 @@ mod tests {
|
|||||||
assert!(matches!(err, AgentSessionError::Decode(_)));
|
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]
|
#[test]
|
||||||
fn parse_jsonl_event_rejects_invalid_json() {
|
fn parse_jsonl_event_rejects_invalid_json() {
|
||||||
let err = parse_jsonl_event("{nope").unwrap_err();
|
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();
|
let event = parse_jsonl_event(r#"{"type":"error","error":"panne réseau"}"#).unwrap();
|
||||||
assert_eq!(event, ParsedEvent::Error("panne réseau".to_owned()));
|
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