//! [`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 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), /// Activité outil OpenCode. ToolActivity(String), /// 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(tool_label(&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()) } /// 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)?; let mut events = Vec::new(); let mut final_text = String::new(); let mut error_seen = false; for record in records { match record.event { ParsedEvent::StepStart => { events.push(ReplyEvent::Heartbeat); } ParsedEvent::StepFinish => { events.push(ReplyEvent::Heartbeat); } ParsedEvent::ToolActivity(label) => { 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 => {} } } 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 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 { .. })) } /// 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>, 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, 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), 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 { 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 expected_session = self.conversation_id(); 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()))? { if let Some(engine_id) = extract_session_id(&line)? { if expected_session .as_deref() .is_none_or(|expected| expected == engine_id) { self.capture_session_id(engine_id); } } 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); let parsed_session = expected_session.or_else(|| self.conversation_id()); let parsed = parse_jsonl_turn_scoped(&collected, parsed_session.as_deref()); 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 = parsed?; Ok(Box::new(events.into_iter())) } async fn shutdown(&self) -> Result<(), AgentSessionError> { Ok(()) } } #[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] 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!( 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!( 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!( 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("bash".to_owned())); } #[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)] #[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!( events, vec![ ReplyEvent::Heartbeat, ReplyEvent::TextDelta { text: "résultat exploitable".to_owned() }, ReplyEvent::Heartbeat, ReplyEvent::Final { content: "résultat exploitable".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")); } }