//! [`OpenCodeSession`] — adapter structuré OpenCode + llama.cpp. //! //! OpenCode est piloté comme host process local : IdeA génère un `opencode.json` //! isolé dans le run dir, OpenCode lance le bridge MCP `idea`, puis chaque tour est //! un `opencode run --format json `. L'adapter ne connaît que le contrat //! JSONL minimal observé/cadré : `step_start`, `text`, `step_finish`. use std::process::Stdio; use std::sync::Arc; use async_trait::async_trait; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use tokio::process::Command; use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream}; use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::SessionId; /// Un événement OpenCode parsé depuis stdout JSONL. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ParsedEvent { /// Début d'étape OpenCode. StepStart, /// Fragment texte assistant. Text(String), /// Fin d'étape OpenCode. StepFinish, /// Evénement JSON valide mais hors contrat minimal. Ignored, } /// Parse une ligne JSONL OpenCode. /// /// # Errors /// [`AgentSessionError::Decode`] si la ligne non vide n'est pas du JSON valide. pub fn parse_jsonl_event(line: &str) -> Result { let trimmed = line.trim(); if trimmed.is_empty() { return Ok(ParsedEvent::Ignored); } let value: Value = serde_json::from_str(trimmed) .map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?; 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), Some("text") => Ok(ParsedEvent::Text( value .get("text") .or_else(|| value.get("content")) .and_then(Value::as_str) .unwrap_or_default() .to_owned(), )), _ => Ok(ParsedEvent::Ignored), } } /// 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. pub fn parse_jsonl_turn(lines: &[String]) -> Result, AgentSessionError> { let mut events = Vec::new(); let mut final_text = String::new(); for line in lines { match parse_jsonl_event(line)? { ParsedEvent::StepStart | ParsedEvent::StepFinish => { events.push(ReplyEvent::Heartbeat); } ParsedEvent::Text(text) => { if !text.is_empty() { final_text.push_str(&text); events.push(ReplyEvent::TextDelta { text }); } } ParsedEvent::Ignored => {} } } if final_text.trim().is_empty() { return Err(AgentSessionError::Decode( "OpenCode n'a produit aucun final textuel".to_owned(), )); } events.push(ReplyEvent::Final { content: final_text, }); Ok(events) } /// Découpe une commande utilisateur en argv sans shell implicite. /// /// Supporte les guillemets simples/doubles et les antislashs. Les expansions shell, /// pipes et substitutions ne sont pas interprétés. pub fn split_command_prefix(raw: &str) -> Result, AgentSessionError> { let mut out = Vec::new(); let mut cur = String::new(); let mut chars = raw.chars().peekable(); let mut quote: Option = None; while let Some(ch) = chars.next() { match (quote, ch) { (Some(q), c) if c == q => quote = None, (None, '\'' | '"') => quote = Some(ch), (_, '\\') => { if let Some(next) = chars.next() { cur.push(next); } else { cur.push('\\'); } } (None, c) if c.is_whitespace() => { if !cur.is_empty() { out.push(std::mem::take(&mut cur)); } } (_, c) => cur.push(c), } } if quote.is_some() { return Err(AgentSessionError::Start( "commande OpenCode invalide: guillemet non fermé".to_owned(), )); } if !cur.is_empty() { out.push(cur); } if out.is_empty() { return Err(AgentSessionError::Start( "commande OpenCode vide".to_owned(), )); } Ok(out) } /// Adapter OpenCode process-backed. pub struct OpenCodeSession { id: SessionId, command: String, prefix_args: Vec, cwd: String, env: Vec<(String, String)>, sandbox: Option, sandbox_enforcer: Option>, } impl OpenCodeSession { /// Construit l'adapter. `command_prefix` peut être `opencode`, un chemin absolu, /// ou un wrapper avec arguments; IdeA ajoute ensuite `run --format json`. pub fn new( id: SessionId, command_prefix: impl Into, profile_args: Vec, cwd: impl Into, env: Vec<(String, String)>, sandbox: Option, sandbox_enforcer: Option>, ) -> Result { let mut prefix = split_command_prefix(&command_prefix.into())?; let command = prefix.remove(0); prefix.extend(profile_args); Ok(Self { id, command, prefix_args: prefix, cwd: cwd.into(), env, sandbox, sandbox_enforcer, }) } fn build_args(&self, prompt: &str) -> Vec { let mut args = self.prefix_args.clone(); args.extend([ "run".to_owned(), "--format".to_owned(), "json".to_owned(), prompt.to_owned(), ]); args } } #[async_trait] impl AgentSession for OpenCodeSession { fn id(&self) -> SessionId { self.id } fn conversation_id(&self) -> Option { None } async fn send(&self, prompt: &str) -> Result { let mut cmd = Command::new(&self.command); cmd.args(self.build_args(prompt)) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); if !self.cwd.is_empty() && self.cwd != "/" { cmd.current_dir(&self.cwd); } for (key, value) in &self.env { cmd.env(key, value); } // OpenCode supporte déjà son propre confinement logique; le plan Landlock // structuré reste réservé aux chemins process génériques existants. let _ = (&self.sandbox, &self.sandbox_enforcer); let mut child = cmd .spawn() .map_err(|e| AgentSessionError::Start(format!("{}: {e}", self.command)))?; let stdout = child .stdout .take() .ok_or_else(|| AgentSessionError::Io("stdout pipe indisponible".to_owned()))?; let mut stderr_pipe = child .stderr .take() .ok_or_else(|| AgentSessionError::Io("stderr pipe indisponible".to_owned()))?; let mut lines = BufReader::new(stdout).lines(); let mut collected = Vec::new(); while let Some(line) = lines .next_line() .await .map_err(|e| AgentSessionError::Io(e.to_string()))? { collected.push(line); } let mut stderr_bytes = Vec::new(); stderr_pipe .read_to_end(&mut stderr_bytes) .await .map_err(|e| AgentSessionError::Io(e.to_string()))?; let status = child .wait() .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(), )); } if !status.success() { return Err(AgentSessionError::Io(format!( "OpenCode a quitté avec le statut {}: {}", status, stderr.trim() ))); } let events = parse_jsonl_turn(&collected)?; Ok(Box::new(events.into_iter())) } async fn shutdown(&self) -> Result<(), AgentSessionError> { Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn split_command_prefix_handles_quotes_without_shell() { assert_eq!( split_command_prefix(r#""/tmp/my opencode" --flag "two words" 'three words'"#).unwrap(), vec!["/tmp/my opencode", "--flag", "two words", "three words"] ); } #[test] 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":"step_finish"}"#.to_owned(), ]) .unwrap(); assert_eq!( events, vec![ ReplyEvent::Heartbeat, ReplyEvent::TextDelta { text: "hel".to_owned() }, ReplyEvent::TextDelta { text: "lo".to_owned() }, ReplyEvent::Heartbeat, ReplyEvent::Final { content: "hello".to_owned() } ] ); } #[test] fn parse_jsonl_turn_rejects_empty_final() { let err = parse_jsonl_turn(&[r#"{"type":"step_start"}"#.to_owned()]).unwrap_err(); assert!(matches!(err, AgentSessionError::Decode(_))); } #[test] fn parse_jsonl_event_rejects_invalid_json() { let err = parse_jsonl_event("{nope").unwrap_err(); assert!(matches!(err, AgentSessionError::Decode(_))); } }