//! [`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 observé/cadré : `step_start`, `text(part.text)`, `tool_use`, //! `step_finish`, `error`. use std::process::Stdio; use std::sync::Arc; use std::sync::Mutex; use async_trait::async_trait; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use tokio::process::Command; use tokio::sync::Mutex as AsyncMutex; use domain::ports::{ AgentSession, AgentSessionError, ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage, 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), /// Activité outil OpenCode. ToolActivity { /// Nom d'outil affichable. label: String, /// Aperçu borné du payload d'appel outil, quand OpenCode l'expose. preview: Option, }, /// Fin d'étape OpenCode. StepFinish, /// Erreur terminale remontée par OpenCode. Error(String), /// Evénement JSON valide mais hors contrat minimal. Ignored, } /// Identifiant natif de session OpenCode exposé dans les événements JSONL. pub fn extract_session_id(line: &str) -> Result, AgentSessionError> { let trimmed = line.trim(); if trimmed.is_empty() { return Ok(None); } let value: Value = serde_json::from_str(trimmed) .map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?; Ok( json_string_field(&value, &["sessionID", "session_id", "sessionId"]).or_else(|| { value .get("part") .and_then(|part| json_string_field(part, &["sessionID", "session_id", "sessionId"])) }), ) } /// Message d'erreur par défaut quand `error` n'est ni une chaîne ni un objet exploitable. const OPENCODE_ERROR_FALLBACK: &str = "OpenCode a signalé une erreur sans détail exploitable"; /// 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 { parse_jsonl_record(line).map(|record| record.event) } #[derive(Debug, Clone)] struct ParsedRecord { event: ParsedEvent, session_id: Option, message_id: Option, } fn parse_jsonl_record(line: &str) -> Result { let trimmed = line.trim(); if trimmed.is_empty() { return Ok(ParsedRecord { event: ParsedEvent::Ignored, session_id: None, message_id: None, }); } let value: Value = serde_json::from_str(trimmed) .map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?; let part = value.get("part"); let session_id = json_string_field(&value, &["sessionID", "session_id", "sessionId"]).or_else(|| { part.and_then(|part| json_string_field(part, &["sessionID", "session_id", "sessionId"])) }); let message_id = json_string_field(&value, &["messageID", "message_id", "messageId"]).or_else(|| { part.and_then(|part| json_string_field(part, &["messageID", "message_id", "messageId"])) }); let event = match value.get("type").and_then(Value::as_str).or_else(|| { part.and_then(|part| part.get("type")) .and_then(Value::as_str) }) { Some("step_start" | "step.start" | "step-start") => ParsedEvent::StepStart, Some("step_finish" | "step.finish" | "step-finish") => ParsedEvent::StepFinish, // Le texte réel est niché sous `.part.text` (cf. `packages/opencode/src/cli/cmd/run.ts`) ; // un seul événement `text` par part, déjà finalisé — pas de delta à dédupliquer. Some("text") => part .and_then(|part| part.get("text")) .or_else(|| value.get("text")) .or_else(|| value.get("content")) .and_then(Value::as_str) .map(|text| ParsedEvent::Text(text.to_owned())) .unwrap_or(ParsedEvent::Ignored), Some("tool_use" | "tool.use" | "tool") => ParsedEvent::ToolActivity { label: tool_label(&value), preview: tool_preview(&value), }, Some("error" | "session.error") => ParsedEvent::Error(opencode_error_message(&value)), _ => ParsedEvent::Ignored, }; Ok(ParsedRecord { event, session_id, message_id, }) } fn json_string_field(value: &Value, names: &[&str]) -> Option { names .iter() .find_map(|name| value.get(*name).and_then(Value::as_str)) .map(str::trim) .filter(|id| !id.is_empty()) .map(str::to_owned) } /// Extrait un message d'erreur exploitable depuis `value.error`, qu'il s'agisse d'une /// chaîne brute ou d'un objet `{message: ...}` (forme exacte non confirmée empiriquement). fn opencode_error_message(value: &Value) -> String { let error = value .get("error") .or_else(|| value.get("properties").and_then(|props| props.get("error"))); error .and_then(Value::as_str) .map(str::to_owned) .or_else(|| { error .and_then(|e| e.get("message")) .and_then(Value::as_str) .map(str::to_owned) }) .or_else(|| { error .and_then(|e| e.get("data")) .and_then(|data| data.get("message")) .and_then(Value::as_str) .map(str::to_owned) }) .or_else(|| { error .and_then(|e| e.get("name")) .and_then(Value::as_str) .map(str::to_owned) }) .unwrap_or_else(|| OPENCODE_ERROR_FALLBACK.to_owned()) } fn tool_label(value: &Value) -> String { let part = value.get("part").unwrap_or(value); json_string_field(part, &["tool", "name"]) .or_else(|| json_string_field(value, &["tool", "name"])) .unwrap_or_else(|| "outil OpenCode".to_owned()) } fn tool_preview(value: &Value) -> Option { let part = value.get("part").unwrap_or(value); non_blank_string(part.get("arguments")) .or_else(|| non_blank_string(part.get("input"))) .or_else(|| non_blank_string(part.get("params"))) .or_else(|| non_blank_string(part.get("command"))) .or_else(|| non_blank_string(value.get("arguments"))) .or_else(|| non_blank_string(value.get("input"))) .or_else(|| non_blank_string(value.get("params"))) .or_else(|| non_blank_string(value.get("command"))) .or_else(|| value_preview(part.get("arguments"))) .or_else(|| value_preview(part.get("input"))) .or_else(|| value_preview(part.get("params"))) .or_else(|| value_preview(value.get("arguments"))) .or_else(|| value_preview(value.get("input"))) .or_else(|| value_preview(value.get("params"))) } fn non_blank_string(value: Option<&Value>) -> Option { value .and_then(Value::as_str) .map(str::trim) .filter(|text| !text.is_empty()) .map(str::to_owned) } fn value_preview(value: Option<&Value>) -> Option { value .filter(|value| value.is_object() || value.is_array()) .map(Value::to_string) .filter(|text| !text.trim().is_empty()) } /// Convertit des lignes JSONL OpenCode en événements domaine. /// /// Le `Final` est la concaténation ordonnée des événements `text` du tour courant. /// Si OpenCode rejoue des événements d'une même session, les identifiants de /// message (`messageID`/`messageId`) servent à conserver seulement le dernier /// message assistant observable. Un tour terminé sans texte exploitable est une /// erreur de décodage, pas un succès vide. pub fn parse_jsonl_turn(lines: &[String]) -> Result, AgentSessionError> { parse_jsonl_turn_scoped(lines, None) } fn parse_jsonl_turn_scoped( lines: &[String], expected_session_id: Option<&str>, ) -> Result, AgentSessionError> { let records = current_turn_records(lines, expected_session_id)?; events_from_records(records) } fn events_from_records(records: Vec) -> Result, AgentSessionError> { let mut events = Vec::new(); let mut final_text = String::new(); let mut error_seen = false; for record in records { append_record_events(record, &mut events, &mut final_text, &mut error_seen); } append_final_event(events, final_text, error_seen) } fn append_record_events( record: ParsedRecord, events: &mut Vec, final_text: &mut String, error_seen: &mut bool, ) { match record.event { ParsedEvent::StepStart => { events.push(ReplyEvent::Progress { progress: provider_progress( ReplyProgressKind::Turn, ReplyProgressStage::Started, "étape démarrée", "step_start", ), }); events.push(ReplyEvent::Heartbeat); } ParsedEvent::StepFinish => { events.push(ReplyEvent::Progress { progress: provider_progress( ReplyProgressKind::Turn, ReplyProgressStage::Completed, "étape terminée", "step_finish", ), }); events.push(ReplyEvent::Heartbeat); } ParsedEvent::ToolActivity { label, preview } => { let mut progress = tool_progress(&label); if let Some(preview) = preview { progress = progress.with_text(preview); } events.push(ReplyEvent::Progress { progress }); events.push(ReplyEvent::ToolActivity { label }); } ParsedEvent::Text(text) => { if !text.is_empty() { final_text.push_str(&text); events.push(ReplyEvent::TextDelta { text }); } } ParsedEvent::Error(message) => { *error_seen = true; events.push(ReplyEvent::Error { message }); } ParsedEvent::Ignored => {} } } fn append_final_event( mut events: Vec, final_text: String, error_seen: bool, ) -> Result, AgentSessionError> { if final_text.trim().is_empty() { if error_seen { return Ok(events); } return Err(AgentSessionError::Decode( "OpenCode n'a produit aucun final textuel exploitable".to_owned(), )); } events.push(ReplyEvent::Final { content: final_text, }); Ok(events) } fn provider_progress( kind: ReplyProgressKind, stage: ReplyProgressStage, label: impl Into, native_event: impl Into, ) -> ReplyProgress { ReplyProgress::new(ReplyProgressSource::ProviderNative, kind, stage, label) .with_provider_event("opencode", native_event) } fn tool_progress(label: &str) -> ReplyProgress { if label.starts_with("idea_") { ReplyProgress::new( ReplyProgressSource::IdeaLocal, ReplyProgressKind::Mcp, ReplyProgressStage::Completed, label.to_owned(), ) .with_tool_name(label.to_owned()) } else { provider_progress( ReplyProgressKind::Tool, ReplyProgressStage::Started, label.to_owned(), "tool_use", ) .with_tool_name(label.to_owned()) } } fn send_tap(tap: &std::sync::mpsc::Sender, event: ReplyEvent) { if !matches!(event, ReplyEvent::Final { .. }) { let _ = tap.send(event); } } #[derive(Default)] struct LiveTurnProjector { expected_session_id: Option, active_message_id: Option, seen_message_id: bool, hold_until_new_message: bool, pending: Vec, ready: Vec, final_text: String, error_seen: bool, } impl LiveTurnProjector { fn new(expected_session_id: Option) -> Self { Self { hold_until_new_message: expected_session_id.is_some(), expected_session_id, ..Self::default() } } fn ingest(&mut self, record: ParsedRecord) -> Vec { if let (Some(expected), Some(actual)) = ( self.expected_session_id.as_deref(), record.session_id.as_deref(), ) { if actual != expected { return Vec::new(); } } if matches!(record.event, ParsedEvent::Ignored) { return Vec::new(); } if let Some(message_id) = record.message_id.clone() { if self.active_message_id.as_deref() != Some(message_id.as_str()) { if self.seen_message_id { self.pending.clear(); self.hold_until_new_message = false; } self.active_message_id = Some(message_id); self.seen_message_id = true; } } if self.hold_until_new_message && self.seen_message_id { self.pending.push(record); return Vec::new(); } self.emit_record(record) } fn finish(mut self) -> Vec { let pending = std::mem::take(&mut self.pending); let mut out = Vec::new(); for record in pending { out.extend(self.emit_record(record)); } out } fn emit_record(&mut self, record: ParsedRecord) -> Vec { let before = self.ready.len(); append_record_events( record, &mut self.ready, &mut self.final_text, &mut self.error_seen, ); self.ready.split_off(before) } } fn current_turn_records( lines: &[String], expected_session_id: Option<&str>, ) -> Result, AgentSessionError> { let mut records = Vec::new(); for line in lines { let record = parse_jsonl_record(line)?; if let (Some(expected), Some(actual)) = (expected_session_id, record.session_id.as_deref()) { if actual != expected { continue; } } if !matches!(record.event, ParsedEvent::Ignored) { records.push(record); } } let Some(current_message_id) = records.iter().rev().find_map(|record| { matches!( record.event, ParsedEvent::Text(_) | ParsedEvent::ToolActivity { .. } | ParsedEvent::StepStart | ParsedEvent::StepFinish | ParsedEvent::Error(_) ) .then(|| record.message_id.as_deref()) .flatten() .map(str::to_owned) }) else { return Ok(records); }; Ok(records .into_iter() .filter(|record| { record .message_id .as_deref() .is_none_or(|message_id| message_id == current_message_id.as_str()) }) .collect()) } fn has_structured_terminal_event(events: &[ReplyEvent]) -> bool { events .iter() .any(|event| matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. })) } fn stream_events(events: Vec, has_tap: bool) -> Vec { if !has_tap { return events; } events .into_iter() .filter(|event| matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. })) .collect() } /// 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)>, engine_session_id: Mutex>, current_child: Mutex>>>, sandbox: Option, sandbox_enforcer: Option>, } impl OpenCodeSession { fn clear_current_child(&self, child: &Arc>) { let mut current = self .current_child .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if current .as_ref() .is_some_and(|stored| Arc::ptr_eq(stored, child)) { *current = None; } } /// 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, seed: Option, 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, engine_session_id: Mutex::new(seed), current_child: Mutex::new(None), 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()]); if let Some(engine_id) = self.conversation_id() { args.extend(["--session".to_owned(), engine_id]); } args.push(prompt.to_owned()); args } fn capture_session_id(&self, engine_id: String) { *self .engine_session_id .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(engine_id); } } #[async_trait] impl AgentSession for OpenCodeSession { fn id(&self) -> SessionId { self.id } fn conversation_id(&self) -> Option { self.engine_session_id .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone() } async fn send(&self, prompt: &str) -> Result { self.send_inner(prompt, None).await } async fn send_with_tap( &self, prompt: &str, tap: std::sync::mpsc::Sender, ) -> Result { self.send_inner(prompt, Some(tap)).await } async fn shutdown(&self) -> Result<(), AgentSessionError> { Ok(()) } async fn cancel_current_turn(&self) -> Result<(), AgentSessionError> { let child = self .current_child .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); if let Some(child) = child { child .lock() .await .kill() .await .map_err(|e| AgentSessionError::Io(format!("annulation OpenCode: {e}")))?; } Ok(()) } } impl OpenCodeSession { async fn send_inner( &self, prompt: &str, tap: Option>, ) -> 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 child = cmd .spawn() .map_err(|e| AgentSessionError::Start(format!("{}: {e}", self.command)))?; let child = Arc::new(AsyncMutex::new(child)); { let mut current = self .current_child .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); *current = Some(Arc::clone(&child)); } let outcome = async { let (stdout, mut stderr_pipe) = { let mut locked = child.lock().await; let stdout = locked.stdout.take().ok_or_else(|| { AgentSessionError::Io("stdout pipe indisponible".to_owned()) })?; let stderr = locked.stderr.take().ok_or_else(|| { AgentSessionError::Io("stderr pipe indisponible".to_owned()) })?; (stdout, stderr) }; let expected_session = self.conversation_id(); let mut lines = BufReader::new(stdout).lines(); let mut collected = Vec::new(); let mut live = tap .as_ref() .map(|_| LiveTurnProjector::new(expected_session.clone())); while let Some(line) = lines .next_line() .await .map_err(|e| AgentSessionError::Io(e.to_string()))? { let record = parse_jsonl_record(&line)?; if let Some(engine_id) = record.session_id.clone() { if expected_session .as_deref() .is_none_or(|expected| expected == engine_id) { self.capture_session_id(engine_id); } } if let (Some(tap), Some(live)) = (&tap, live.as_mut()) { for event in live.ingest(record) { send_tap(tap, event); } } collected.push(line); } if let (Some(tap), Some(live)) = (&tap, live) { for event in live.finish() { send_tap(tap, event); } } 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 .lock() .await .wait() .await .map_err(|e| AgentSessionError::Io(e.to_string()))?; Ok((expected_session, collected, stderr_bytes, status)) } .await; self.clear_current_child(&child); let (expected_session, collected, stderr_bytes, status) = outcome?; let stderr = String::from_utf8_lossy(&stderr_bytes); let parsed_session = expected_session.or_else(|| self.conversation_id()); let parsed = parse_jsonl_turn_scoped(&collected, parsed_session.as_deref()); let has_tap = tap.is_some(); if !status.success() { if let Ok(events) = parsed { if has_structured_terminal_event(&events) { return Ok(Box::new(stream_events(events, has_tap).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 = parsed?; Ok(Box::new(stream_events(events, has_tap).into_iter())) } } #[cfg(test)] mod tests { #[cfg(unix)] use std::fs; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; #[cfg(unix)] use std::path::{Path, PathBuf}; #[cfg(unix)] use std::time::Duration; use super::*; fn without_progress(events: Vec) -> Vec { events .into_iter() .filter(|event| !matches!(event, ReplyEvent::Progress { .. })) .collect() } #[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","part":{"text":"hel"}}"#.to_owned(), r#"{"type":"text","part":{"text":"lo"}}"#.to_owned(), r#"{"type":"step_finish"}"#.to_owned(), ]) .unwrap(); assert_eq!( without_progress(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_turn_rejects_tool_only_finished_turn() { let err = parse_jsonl_turn(&[ r#"{"type":"step_start"}"#.to_owned(), r#"{"type":"step_finish"}"#.to_owned(), ]) .unwrap_err(); assert!( matches!(err, AgentSessionError::Decode(ref message) if message.contains("aucun final textuel exploitable")), "tool-only turn must be actionable, not a fake empty success: {err:?}" ); } #[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(); assert!(matches!(err, AgentSessionError::Decode(_))); } /// Format JSONL réel de `opencode run --format json`, confirmé par lecture du /// source `packages/opencode/src/cli/cmd/run.ts` : le texte est niché sous /// `.part.text`, pas à la racine. fn opencode_script() -> Vec<&'static str> { vec![ r#"{"type":"step_start","timestamp":1,"sessionID":"s1"}"#, r#"{"type":"text","timestamp":2,"sessionID":"s1","part":{"id":"p1","type":"text","text":"réponse réelle","time":{"start":1,"end":2}}}"#, r#"{"type":"step_finish","timestamp":3,"sessionID":"s1"}"#, ] } #[test] fn parse_jsonl_turn_reads_text_nested_under_part() { let lines: Vec = opencode_script().into_iter().map(str::to_owned).collect(); let events = parse_jsonl_turn(&lines).unwrap(); assert_eq!( without_progress(events), vec![ ReplyEvent::Heartbeat, ReplyEvent::TextDelta { text: "réponse réelle".to_owned() }, ReplyEvent::Heartbeat, ReplyEvent::Final { content: "réponse réelle".to_owned() } ] ); } #[test] fn parse_jsonl_turn_emits_error_event() { let events = parse_jsonl_turn(&[ r#"{"type":"step_start","timestamp":1,"sessionID":"s1"}"#.to_owned(), r#"{"type":"error","timestamp":2,"sessionID":"s1","error":{"message":"quelque chose a échoué"}}"# .to_owned(), ]) .unwrap(); assert_eq!( without_progress(events), vec![ ReplyEvent::Heartbeat, ReplyEvent::Error { message: "quelque chose a échoué".to_owned() } ] ); } #[test] fn parse_jsonl_event_error_accepts_raw_string() { let event = parse_jsonl_event(r#"{"type":"error","error":"panne réseau"}"#).unwrap(); assert_eq!(event, ParsedEvent::Error("panne réseau".to_owned())); } #[test] fn parse_jsonl_event_accepts_tool_use() { let event = parse_jsonl_event(r#"{"type":"tool_use","part":{"tool":"bash"}}"#).unwrap(); assert_eq!( event, ParsedEvent::ToolActivity { label: "bash".to_owned(), preview: None, } ); } #[test] fn parse_jsonl_turn_tags_idea_tools_as_mcp_progress() { let events = parse_jsonl_turn(&[ r#"{"type":"tool_use","part":{"tool":"idea_ask_agent"}}"#.to_owned(), r#"{"type":"text","part":{"text":"ok"}}"#.to_owned(), ]) .unwrap(); assert!( events.iter().any(|event| matches!( event, ReplyEvent::Progress { progress } if progress.source == ReplyProgressSource::IdeaLocal && progress.kind == ReplyProgressKind::Mcp && progress.stage == ReplyProgressStage::Completed && progress.tool_name.as_deref() == Some("idea_ask_agent") )), "les tools idea_* doivent être surfacés comme MCP IdeA local: {events:?}" ); assert!( !events.iter().any(|event| matches!( event, ReplyEvent::Progress { progress } if progress.source == ReplyProgressSource::ProviderNative && progress.tool_name.as_deref() == Some("idea_ask_agent") )), "le tool MCP ne doit pas produire une double carte provider-native: {events:?}" ); } #[test] fn parse_jsonl_turn_keeps_latest_message_when_opencode_replays_history() { let events = parse_jsonl_turn(&[ r#"{"type":"step_start","sessionID":"s1","part":{"messageID":"old","type":"step-start"}}"# .to_owned(), r#"{"type":"text","sessionID":"s1","part":{"messageID":"old","type":"text","text":"ancienne réponse"}}"# .to_owned(), r#"{"type":"step_finish","sessionID":"s1","part":{"messageID":"old","type":"step-finish"}}"# .to_owned(), r#"{"type":"step_start","sessionID":"s1","part":{"messageID":"new","type":"step-start"}}"# .to_owned(), r#"{"type":"text","sessionID":"s1","part":{"messageID":"new","type":"text","text":"réponse courante"}}"# .to_owned(), r#"{"type":"step_finish","sessionID":"s1","part":{"messageID":"new","type":"step-finish"}}"# .to_owned(), ]) .unwrap(); assert_eq!( events.last(), Some(&ReplyEvent::Final { content: "réponse courante".to_owned() }) ); assert!( !events.iter().any(|event| matches!( event, ReplyEvent::TextDelta { text } if text.contains("ancienne") )), "replayed text from a previous message must not pollute the current final: {events:?}" ); } #[test] fn parse_jsonl_turn_scoped_ignores_other_sessions_when_known() { let events = parse_jsonl_turn_scoped( &[ r#"{"type":"text","sessionID":"other","part":{"messageID":"m1","text":"pollution"}}"# .to_owned(), r#"{"type":"text","sessionID":"current","part":{"messageID":"m2","text":"ok"}}"# .to_owned(), ], Some("current"), ) .unwrap(); assert_eq!( events.last(), Some(&ReplyEvent::Final { content: "ok".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)] fn write_executable_script(path: &Path, body: impl AsRef) { fs::write(path, body.as_ref()).unwrap(); let mut permissions = fs::metadata(path).unwrap().permissions(); permissions.set_mode(0o755); fs::set_permissions(path, permissions).unwrap(); } #[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(), None, Vec::new(), None, None, ) .unwrap(); let events = session.send("prompt").await.unwrap().collect::>(); assert_eq!( without_progress(events), vec![ ReplyEvent::Heartbeat, ReplyEvent::TextDelta { text: "résultat exploitable".to_owned() }, ReplyEvent::Heartbeat, ReplyEvent::Final { content: "résultat exploitable".to_owned() } ] ); } #[cfg(unix)] #[tokio::test] async fn send_with_tap_emits_progress_live_before_final_and_tags_idea_tool_as_mcp() { let tmp = TempDir::new("tap-live"); let script = tmp.path().join("opencode-fixture.sh"); write_executable_script( &script, r#"#!/bin/sh printf '%s\n' '{"type":"step_start","sessionID":"fresh-engine","part":{"messageID":"m1","type":"step-start"}}' sleep 1 printf '%s\n' '{"type":"tool_use","sessionID":"fresh-engine","part":{"messageID":"m1","type":"tool","tool":"idea_ask_agent"}}' printf '%s\n' '{"type":"text","sessionID":"fresh-engine","part":{"messageID":"m1","type":"text","text":"réponse live"}}' printf '%s\n' '{"type":"step_finish","sessionID":"fresh-engine","part":{"messageID":"m1","type":"step-finish"}}' "#, ); let session = Arc::new( OpenCodeSession::new( SessionId::new_random(), script.to_string_lossy(), Vec::new(), tmp.path().to_string_lossy(), None, Vec::new(), None, None, ) .unwrap(), ); let (tx, rx) = std::sync::mpsc::channel(); let worker = { let session = Arc::clone(&session); tokio::spawn(async move { session .send_with_tap("prompt", tx) .await .map(|stream| stream.collect::>()) }) }; let (first_live, rx) = tokio::task::spawn_blocking(move || { let first_live = rx .recv_timeout(Duration::from_millis(500)) .expect("un événement de progress doit atteindre le tap avant le Final"); (first_live, rx) }) .await .unwrap(); assert!( matches!( first_live, ReplyEvent::Progress { ref progress } if progress.native_event.as_deref() == Some("step_start") ), "le premier événement live doit être le step_start provider: {first_live:?}" ); let returned = worker.await.unwrap().unwrap(); let live: Vec<_> = std::iter::once(first_live).chain(rx.into_iter()).collect(); assert!( live.iter() .all(|event| !matches!(event, ReplyEvent::Final { .. })), "le tap ne doit pas recevoir le Final autoritaire: {live:?}" ); assert!( live.iter().any(|event| matches!( event, ReplyEvent::Progress { progress } if progress.source == ReplyProgressSource::IdeaLocal && progress.kind == ReplyProgressKind::Mcp && progress.stage == ReplyProgressStage::Completed && progress.tool_name.as_deref() == Some("idea_ask_agent") )), "le tool_use idea_* doit produire une progression MCP locale: {live:?}" ); assert_eq!( returned, vec![ReplyEvent::Final { content: "réponse live".to_owned() }] ); } #[cfg(unix)] #[tokio::test] async fn send_with_tap_does_not_replay_previous_message_into_live_stream() { let tmp = TempDir::new("tap-replay"); let script = tmp.path().join("opencode-fixture.sh"); write_executable_script( &script, r#"#!/bin/sh printf '%s\n' '{"type":"step_start","sessionID":"seeded-engine","part":{"messageID":"old","type":"step-start"}}' printf '%s\n' '{"type":"text","sessionID":"seeded-engine","part":{"messageID":"old","type":"text","text":"ancienne réponse"}}' printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messageID":"old","type":"step-finish"}}' printf '%s\n' '{"type":"step_start","sessionID":"seeded-engine","part":{"messageID":"new","type":"step-start"}}' printf '%s\n' '{"type":"text","sessionID":"seeded-engine","part":{"messageID":"new","type":"text","text":"réponse courante"}}' printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messageID":"new","type":"step-finish"}}' "#, ); let session = OpenCodeSession::new( SessionId::new_random(), script.to_string_lossy(), Vec::new(), tmp.path().to_string_lossy(), Some("seeded-engine".to_owned()), Vec::new(), None, None, ) .unwrap(); let (tx, rx) = std::sync::mpsc::channel(); let returned = session .send_with_tap("prompt", tx) .await .unwrap() .collect::>(); let live: Vec<_> = rx.into_iter().collect(); assert!( !live.iter().any(|event| matches!( event, ReplyEvent::TextDelta { text } if text.contains("ancienne") )), "le texte rejoué ne doit pas polluer le tap live: {live:?}" ); assert!( live.iter().any(|event| matches!( event, ReplyEvent::TextDelta { text } if text == "réponse courante" )), "le message courant doit rester visible dans le tap live: {live:?}" ); assert_eq!( returned, vec![ReplyEvent::Final { content: "réponse courante".to_owned() }] ); } #[test] fn extract_session_id_accepts_opencode_jsonl_session_id_variants() { assert_eq!( extract_session_id(r#"{"type":"step_start","sessionID":"s1"}"#).unwrap(), Some("s1".to_owned()) ); assert_eq!( extract_session_id(r#"{"type":"step_start","session_id":"s2"}"#).unwrap(), Some("s2".to_owned()) ); assert_eq!( extract_session_id(r#"{"type":"step_start","sessionId":"s3"}"#).unwrap(), Some("s3".to_owned()) ); assert_eq!( extract_session_id(r#"{"type":"step_start","sessionID":" "}"#).unwrap(), None ); } #[cfg(unix)] #[tokio::test] async fn send_captures_engine_session_id_from_jsonl() { let tmp = TempDir::new("capture-session"); let script = tmp.path().join("opencode-fixture.sh"); fs::write( &script, r#"#!/bin/sh printf '%s\n' '{"type":"step_start","sessionID":"opencode-engine-1"}' printf '%s\n' '{"type":"text","sessionID":"opencode-engine-1","part":{"text":"ok"}}' printf '%s\n' '{"type":"step_finish","sessionID":"opencode-engine-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(), None, Vec::new(), None, None, ) .unwrap(); assert_eq!(session.conversation_id(), None); let events = session.send("prompt").await.unwrap().collect::>(); assert!(matches!(events.last(), Some(ReplyEvent::Final { .. }))); assert_eq!( session.conversation_id().as_deref(), Some("opencode-engine-1") ); } #[cfg(unix)] #[tokio::test] async fn seeded_or_captured_session_id_is_passed_as_opencode_session_arg() { let tmp = TempDir::new("resume-arg"); let script = tmp.path().join("opencode-fixture.sh"); let args_file = tmp.path().join("args.txt"); fs::write( &script, format!( r#"#!/bin/sh printf '%s\n' "$@" > '{}' printf '%s\n' '{{"type":"step_start","sessionID":"seeded-engine"}}' printf '%s\n' '{{"type":"text","sessionID":"seeded-engine","part":{{"text":"ok"}}}}' printf '%s\n' '{{"type":"step_finish","sessionID":"seeded-engine"}}' "#, args_file.display() ), ) .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(), Some("seeded-engine".to_owned()), Vec::new(), None, None, ) .unwrap(); session.send("prompt").await.unwrap().for_each(drop); let args = fs::read_to_string(args_file).unwrap(); let argv: Vec<&str> = args.lines().collect(); assert!( argv.windows(2) .any(|pair| pair == ["--session", "seeded-engine"]), "OpenCode resume must use `--session `, got: {argv:?}" ); assert_eq!(argv.last(), Some(&"prompt")); } #[cfg(unix)] #[tokio::test] async fn seeded_session_ignores_and_does_not_capture_other_session_events() { let tmp = TempDir::new("ignore-other-session"); let script = tmp.path().join("opencode-fixture.sh"); fs::write( &script, r#"#!/bin/sh printf '%s\n' '{"type":"step_start","sessionID":"seeded-engine","part":{"messageID":"m1"}}' printf '%s\n' '{"type":"text","sessionID":"seeded-engine","part":{"messageID":"m1","text":"réponse seed"}}' printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messageID":"m1"}}' printf '%s\n' '{"type":"text","sessionID":"other-engine","part":{"messageID":"m2","text":"pollution"}}' "#, ) .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(), Some("seeded-engine".to_owned()), Vec::new(), None, None, ) .unwrap(); let events = session.send("prompt").await.unwrap().collect::>(); assert_eq!( events.last(), Some(&ReplyEvent::Final { content: "réponse seed".to_owned() }) ); assert_eq!(session.conversation_id().as_deref(), Some("seeded-engine")); } }