feat(opencode): remplace le profil Ollama HTTP par OpenCode

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 15:48:07 +02:00
parent 2fa226e413
commit eaba05d27d
19 changed files with 734 additions and 45 deletions

View File

@ -20,7 +20,7 @@
use domain::ids::ProfileId; use domain::ids::ProfileId;
use domain::permission::ProjectorKey; use domain::permission::ProjectorKey;
use domain::profile::{ use domain::profile::{
AgentProfile, ContextInjection, HttpChatConfig, McpCapability, McpConfigStrategy, McpTransport, AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig,
StructuredAdapter, StructuredAdapter,
}; };
@ -97,29 +97,27 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
McpTransport::Stdio, McpTransport::Stdio,
)), )),
AgentProfile::new( AgentProfile::new(
reference_id("ollama-openai-compatible"), reference_id("opencode-ollama"),
"Ollama / OpenAI-compatible local model", "OpenCode + Ollama",
"openai-compatible", "opencode",
Vec::new(), Vec::new(),
ContextInjection::convention_file("AGENTS.md") ContextInjection::convention_file("AGENTS.md")
.expect("AGENTS.md is a valid convention target"), .expect("AGENTS.md is a valid convention target"),
None, Some("opencode --version".to_owned()),
"{agentRunDir}", "{agentRunDir}",
None, None,
) )
.expect("OpenAI-compatible reference profile is valid") .expect("OpenCode reference profile is valid")
.with_structured_adapter(StructuredAdapter::OpenAiCompatible) .with_structured_adapter(StructuredAdapter::OpenCode)
.with_chat_http( .with_opencode(
HttpChatConfig::new( OpenCodeConfig::new(Some("ollama/qwen3-coder:30b".to_owned()))
"http://localhost:11434/v1", .expect("OpenCode config is valid"),
"qwen2.5-coder",
None,
Some(120_000),
Some(5_000),
Some(HttpChatConfig::DEFAULT_MAX_TOOL_ITERATIONS),
) )
.expect("OpenAI-compatible HTTP config is valid"), .with_mcp(McpCapability::new(
), McpConfigStrategy::open_code_config("opencode.json")
.expect("opencode.json is a valid relative config target"),
McpTransport::Stdio,
)),
AgentProfile::new( AgentProfile::new(
reference_id("gemini"), reference_id("gemini"),
"Gemini CLI", "Gemini CLI",
@ -227,16 +225,23 @@ mod mcp_tests {
} }
#[test] #[test]
fn openai_compatible_seed_is_selectable_http_profile_without_mcp() { fn opencode_ollama_seed_replaces_http_ollama_profile() {
let profile = profile("ollama-openai-compatible"); let profile = profile("opencode-ollama");
assert_eq!( assert_eq!(
profile.structured_adapter, profile.structured_adapter,
Some(StructuredAdapter::OpenAiCompatible) Some(StructuredAdapter::OpenCode)
); );
let chat = profile.chat_http.as_ref().expect("chatHttp present"); assert_eq!(profile.command, "opencode");
assert_eq!(chat.endpoint, "http://localhost:11434/v1"); assert!(profile.chat_http.is_none());
assert_eq!(chat.model, "qwen2.5-coder"); assert_eq!(
assert!(profile.mcp.is_none()); profile
.opencode
.as_ref()
.and_then(|config| config.model.as_deref()),
Some("ollama/qwen3-coder:30b")
);
assert!(profile.mcp.is_some());
assert!(profile.materializes_idea_bridge());
assert!(profile.is_selectable()); assert!(profile.is_selectable());
} }

View File

@ -1737,6 +1737,7 @@ impl LaunchAgent {
&input.project.root, &input.project.root,
input.node_id, input.node_id,
size, size,
&spec.env,
spec.sandbox.as_ref(), spec.sandbox.as_ref(),
) )
.await; .await;
@ -1802,12 +1803,13 @@ impl LaunchAgent {
root: &ProjectPath, root: &ProjectPath,
node_id: Option<NodeId>, node_id: Option<NodeId>,
size: PtySize, size: PtySize,
env: &[(String, String)],
sandbox: Option<&SandboxPlan>, sandbox: Option<&SandboxPlan>,
) -> Result<LaunchAgentOutput, AppError> { ) -> Result<LaunchAgentOutput, AppError> {
// Relaie le plan de sandbox OS (lot LP4-4) à la fabrique : `spec.sandbox`, // Relaie le plan de sandbox OS (lot LP4-4) à la fabrique : `spec.sandbox`,
// déjà compilé (pur, domaine) en step 5d. `None` ⇒ exécution native inchangée. // déjà compilé (pur, domaine) en step 5d. `None` ⇒ exécution native inchangée.
let session = factory let session = factory
.start(profile, prepared, run_dir, session_plan, sandbox) .start(profile, prepared, run_dir, session_plan, env, sandbox)
.await .await
.map_err(|e| AppError::Process(e.to_string()))?; .map_err(|e| AppError::Process(e.to_string()))?;
@ -2286,6 +2288,39 @@ impl LaunchAgent {
let home_dir = parent_dir(run_dir, target); let home_dir = parent_dir(run_dir, target);
spec.env.push((home_env.clone(), home_dir)); spec.env.push((home_env.clone(), home_dir));
} }
domain::profile::McpConfigStrategy::OpenCodeConfig { target } => {
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
return;
}
let config_path = join(run_dir, target);
let opencode_home = join(run_dir, ".opencode");
let xdg_config = format!("{opencode_home}/config");
let xdg_data = format!("{opencode_home}/data");
let xdg_cache = format!("{opencode_home}/cache");
for dir in [&opencode_home, &xdg_config, &xdg_data, &xdg_cache] {
let _ = self.fs.create_dir_all(&RemotePath::new(dir.clone())).await;
}
if let Some(parent) = parent_rel(target) {
let _ = self
.fs
.create_dir_all(&RemotePath::new(format!("{}/{parent}", run_dir.as_str())))
.await;
}
let body =
opencode_config_json(profile, project_root.as_str(), runtime).to_string();
let _ = self
.fs
.write(&RemotePath::new(config_path.clone()), body.as_bytes())
.await;
spec.env.extend([
("OPENCODE_CONFIG".to_owned(), config_path),
("HOME".to_owned(), opencode_home),
("XDG_CONFIG_HOME".to_owned(), xdg_config),
("XDG_DATA_HOME".to_owned(), xdg_data),
("XDG_CACHE_HOME".to_owned(), xdg_cache),
("OPENCODE_DISABLE_AUTOUPDATE".to_owned(), "1".to_owned()),
]);
}
domain::profile::McpConfigStrategy::Flag { flag } => { domain::profile::McpConfigStrategy::Flag { flag } => {
// Pass the server via a launch flag (e.g. `--mcp-config {path}`). The // Pass the server via a launch flag (e.g. `--mcp-config {path}`). The
// config path is the run dir itself (the CLI's cwd), where the server // config path is the run dir itself (the CLI's cwd), where the server
@ -2434,6 +2469,98 @@ fn mcp_server_wiring(
domain::McpServerWiring::new(command, args, transport) domain::McpServerWiring::new(command, args, transport)
} }
/// Renders the OpenCode config managed by IdeA in the agent run dir.
///
/// The MCP server is named `idea`; OpenCode therefore exposes IdeA tools as
/// `idea_idea_*` (`<serverName>_<toolName>`), matching the observed contract.
fn opencode_config_json(
profile: &AgentProfile,
project_root: &str,
runtime: Option<&McpRuntime>,
) -> serde_json::Value {
let model = profile
.opencode
.as_ref()
.and_then(|config| config.model.as_deref());
let mut root = serde_json::Map::new();
root.insert(
"$schema".to_owned(),
serde_json::Value::String("https://opencode.ai/config.json".to_owned()),
);
if let Some(model) = model {
root.insert(
"model".to_owned(),
serde_json::Value::String(model.to_owned()),
);
}
let mut models = serde_json::Map::new();
if let Some(model) = model {
models.insert(
model.trim_start_matches("ollama/").to_owned(),
serde_json::json!({ "name": model }),
);
}
root.insert(
"provider".to_owned(),
serde_json::json!({
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"name": "Ollama (local)",
"options": {
"baseURL": "http://localhost:11434/v1"
},
"models": models
}
}),
);
let (command, args) = match runtime {
Some(rt) => (
rt.exe.clone(),
vec![
"mcp-server".to_owned(),
"--endpoint".to_owned(),
rt.endpoint.clone(),
"--project".to_owned(),
rt.project_id.clone(),
"--requester".to_owned(),
rt.requester.clone(),
],
),
None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
};
let command_array = std::iter::once(command)
.chain(args)
.map(serde_json::Value::String)
.collect::<Vec<_>>();
root.insert(
"mcp".to_owned(),
serde_json::json!({
"idea": {
"type": "local",
"command": command_array,
"cwd": project_root,
"enabled": true,
"timeout": 15000
}
}),
);
root.insert(
"permission".to_owned(),
serde_json::json!({
"bash": "ask",
"edit": "ask"
}),
);
root.insert(
"disabled_providers".to_owned(),
serde_json::json!(["anthropic", "openai", "gemini"]),
);
serde_json::Value::Object(root)
}
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative /// Builds an absolute path string by joining a [`ProjectPath`] with a relative
/// segment using a POSIX separator. /// segment using a POSIX separator.
fn join(base: &ProjectPath, rel: &str) -> String { fn join(base: &ProjectPath, rel: &str) -> String {
@ -2441,6 +2568,10 @@ fn join(base: &ProjectPath, rel: &str) -> String {
format!("{b}/{rel}") format!("{b}/{rel}")
} }
fn parent_rel(rel: &str) -> Option<&str> {
rel.rsplit_once(['/', '\\']).map(|(parent, _)| parent)
}
/// Resolves the **parent directory** (absolute) of `<base>/<rel>` — used to point a /// Resolves the **parent directory** (absolute) of `<base>/<rel>` — used to point a
/// CLI's `home_env` (e.g. `CODEX_HOME`) at the directory *containing* the materialised /// CLI's `home_env` (e.g. `CODEX_HOME`) at the directory *containing* the materialised
/// config file (`config.toml`), not the file itself. When `rel` has no separator /// config file (`config.toml`), not the file itself. When `rel` has no separator

View File

@ -101,6 +101,7 @@ impl OpenTicketAssistant {
&prepared, &prepared,
&input.project.root, &input.project.root,
&SessionPlan::None, &SessionPlan::None,
&[],
None, None,
) )
.await .await

View File

@ -34,7 +34,7 @@ use domain::ports::{
RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
}; };
use domain::profile::{ use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig,
SessionStrategy, StructuredAdapter, SessionStrategy, StructuredAdapter,
}; };
use domain::project::{Project, ProjectPath}; use domain::project::{Project, ProjectPath};
@ -1584,6 +1584,16 @@ fn profile_with_mcp(id: ProfileId, config: McpConfigStrategy) -> AgentProfile {
.with_mcp(McpCapability::new(config, McpTransport::Stdio)) .with_mcp(McpCapability::new(config, McpTransport::Stdio))
} }
fn opencode_profile(id: ProfileId) -> AgentProfile {
profile(id, ContextInjection::convention_file("AGENTS.md").unwrap())
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode(OpenCodeConfig::new(Some("ollama/qwen3-coder:30b".to_owned())).unwrap())
.with_mcp(McpCapability::new(
McpConfigStrategy::open_code_config("opencode.json").unwrap(),
McpTransport::Stdio,
))
}
/// Returns the writes whose path ends with the given suffix (e.g. `/.mcp.json`). /// Returns the writes whose path ends with the given suffix (e.g. `/.mcp.json`).
fn writes_ending_with(fs: &FakeFs, suffix: &str) -> Vec<(String, Vec<u8>)> { fn writes_ending_with(fs: &FakeFs, suffix: &str) -> Vec<(String, Vec<u8>)> {
fs.writes() fs.writes()
@ -1703,6 +1713,62 @@ async fn launch_mcp_config_file_write_failure_is_best_effort() {
assert_eq!(pty.spawns().len(), 1, "the CLI is still spawned"); assert_eq!(pty.spawns().len(), 1, "the CLI is still spawned");
} }
#[tokio::test]
async fn launch_opencode_writes_isolated_config_and_env() {
let profile = opencode_profile(pid(9));
let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile(
profile,
Some(ContextInjectionPlan::File {
target: "AGENTS.md".to_owned(),
}),
);
launch.execute(launch_input(agent.id)).await.unwrap();
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
let opencode = writes_ending_with(&fs, "/opencode.json");
assert_eq!(opencode.len(), 1, "one OpenCode config is written");
assert_eq!(opencode[0].0, format!("{run_dir}/opencode.json"));
let body: serde_json::Value = serde_json::from_slice(&opencode[0].1).unwrap();
assert_eq!(body["model"], "ollama/qwen3-coder:30b");
assert_eq!(
body["provider"]["ollama"]["options"]["baseURL"],
"http://localhost:11434/v1"
);
assert_eq!(body["mcp"]["idea"]["type"], "local");
assert_eq!(body["mcp"]["idea"]["command"][0], "idea");
let spawn = pty
.spawns()
.pop()
.expect("spawned via PTY fallback in this fixture");
let env: std::collections::HashMap<_, _> = spawn.env.into_iter().collect();
assert_eq!(
env.get("OPENCODE_CONFIG").map(String::as_str),
Some(format!("{run_dir}/opencode.json").as_str())
);
assert_eq!(
env.get("HOME").map(String::as_str),
Some(format!("{run_dir}/.opencode").as_str())
);
assert_eq!(
env.get("XDG_CONFIG_HOME").map(String::as_str),
Some(format!("{run_dir}/.opencode/config").as_str())
);
assert_eq!(
env.get("XDG_DATA_HOME").map(String::as_str),
Some(format!("{run_dir}/.opencode/data").as_str())
);
assert_eq!(
env.get("XDG_CACHE_HOME").map(String::as_str),
Some(format!("{run_dir}/.opencode/cache").as_str())
);
assert_eq!(
env.get("OPENCODE_DISABLE_AUTOUPDATE").map(String::as_str),
Some("1")
);
}
#[tokio::test] #[tokio::test]
async fn launch_mcp_flag_appends_flag_and_path_to_args() { async fn launch_mcp_flag_appends_flag_and_path_to_args() {
// Test 5 — Flag { flag: "--mcp-config" } ⇒ spec.args carries the flag followed // Test 5 — Flag { flag: "--mcp-config" } ⇒ spec.args carries the flag followed

View File

@ -1481,6 +1481,7 @@ impl AgentSessionFactory for CompletionFactory {
ctx: &PreparedContext, ctx: &PreparedContext,
_cwd: &ProjectPath, _cwd: &ProjectPath,
_session: &SessionPlan, _session: &SessionPlan,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>, _sandbox: Option<&domain::sandbox::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> { ) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
let id = { let id = {
@ -4085,6 +4086,7 @@ impl AgentSessionFactory for CountingFactory {
_ctx: &PreparedContext, _ctx: &PreparedContext,
_cwd: &ProjectPath, _cwd: &ProjectPath,
_session: &SessionPlan, _session: &SessionPlan,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>, _sandbox: Option<&domain::sandbox::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> { ) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
self.starts.fetch_add(1, Ordering::SeqCst); self.starts.fetch_add(1, Ordering::SeqCst);

View File

@ -251,7 +251,7 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() {
let out = uc.execute().await.unwrap(); let out = uc.execute().await.unwrap();
assert!(out.is_first_run); assert!(out.is_first_run);
// §17.3/D7: the wizard is seeded only with the *selectable* (structured- // §17.3/D7: the wizard is seeded only with the *selectable* (structured-
// drivable) profiles — Claude + Codex + OpenAI-compatible — not the full // drivable) profiles — Claude + Codex + OpenCode — not the full
// catalogue. // catalogue.
assert_eq!( assert_eq!(
out.reference_profiles.len(), out.reference_profiles.len(),
@ -265,7 +265,7 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() {
.collect(); .collect();
assert_eq!( assert_eq!(
commands, commands,
vec!["claude", "codex", "openai-compatible"], vec!["claude", "codex", "opencode"],
"only structured profiles offered" "only structured profiles offered"
); );
// Every seeded profile is selectable (the gate the menu relies on). // Every seeded profile is selectable (the gate the menu relies on).
@ -334,12 +334,12 @@ async fn delete_unknown_is_not_found_error() {
#[tokio::test] #[tokio::test]
async fn reference_profiles_use_case_returns_only_selectable() { async fn reference_profiles_use_case_returns_only_selectable() {
// §17.3/D7: the selection use case exposes only structured-drivable profiles // §17.3/D7: the selection use case exposes only structured-drivable profiles
// (Claude + Codex + OpenAI-compatible). Gemini/Aider stay in the raw catalogue (see // (Claude + Codex + OpenCode). Gemini/Aider stay in the raw catalogue (see
// `catalogue_*` tests below) but are not offered to selection/creation. // `catalogue_*` tests below) but are not offered to selection/creation.
let out = ReferenceProfiles::new().execute().await.unwrap(); let out = ReferenceProfiles::new().execute().await.unwrap();
assert_eq!(out.profiles.len(), 3); assert_eq!(out.profiles.len(), 3);
let commands: Vec<&str> = out.profiles.iter().map(|p| p.command.as_str()).collect(); let commands: Vec<&str> = out.profiles.iter().map(|p| p.command.as_str()).collect();
assert_eq!(commands, vec!["claude", "codex", "openai-compatible"]); assert_eq!(commands, vec!["claude", "codex", "opencode"]);
assert!(out.profiles.iter().all(AgentProfile::is_selectable)); assert!(out.profiles.iter().all(AgentProfile::is_selectable));
} }
@ -353,7 +353,7 @@ fn raw_catalogue_still_has_all_profiles() {
.collect(); .collect();
assert_eq!( assert_eq!(
commands, commands,
vec!["claude", "codex", "openai-compatible", "gemini", "aider"] vec!["claude", "codex", "opencode", "gemini", "aider"]
); );
} }
@ -397,8 +397,8 @@ fn is_selectable_is_true_only_for_structured_profiles() {
"Codex carries a structured adapter ⇒ selectable" "Codex carries a structured adapter ⇒ selectable"
); );
assert!( assert!(
by_command["openai-compatible"].is_selectable(), by_command["opencode"].is_selectable(),
"OpenAI-compatible carries a structured adapter ⇒ selectable" "OpenCode carries a structured adapter ⇒ selectable"
); );
assert!( assert!(
!by_command["gemini"].is_selectable(), !by_command["gemini"].is_selectable(),
@ -435,6 +435,12 @@ fn catalogue_has_expected_commands_and_injection() {
Some(CODEX_SUBMIT_DELAY_MS), Some(CODEX_SUBMIT_DELAY_MS),
"Codex TUI needs a conservative text→submit delay for delegated prompts" "Codex TUI needs a conservative text→submit delay for delegated prompts"
); );
assert_eq!(
by_command["opencode"].context_injection,
ContextInjection::ConventionFile {
target: "AGENTS.md".to_owned()
}
);
assert_eq!( assert_eq!(
by_command["gemini"].context_injection, by_command["gemini"].context_injection,
ContextInjection::ConventionFile { ContextInjection::ConventionFile {
@ -484,6 +490,11 @@ fn catalogue_claude_and_codex_carry_their_structured_adapter() {
Some(StructuredAdapter::Codex), Some(StructuredAdapter::Codex),
"Codex reference profile must declare the Codex structured adapter" "Codex reference profile must declare the Codex structured adapter"
); );
assert_eq!(
by_command["opencode"].structured_adapter,
Some(StructuredAdapter::OpenCode),
"OpenCode reference profile must declare the OpenCode structured adapter"
);
} }
#[test] #[test]

View File

@ -522,6 +522,7 @@ impl AgentSessionFactory for FakeFactory {
_ctx: &PreparedContext, _ctx: &PreparedContext,
_cwd: &ProjectPath, _cwd: &ProjectPath,
session: &SessionPlan, session: &SessionPlan,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>, _sandbox: Option<&domain::sandbox::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> { ) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
self.starts self.starts
@ -1119,7 +1120,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
let cwd = ProjectPath::new(ROOT).unwrap(); let cwd = ProjectPath::new(ROOT).unwrap();
let session = f let session = f
.factory .factory
.start(&profile, &ctx, &cwd, &SessionPlan::None, None) .start(&profile, &ctx, &cwd, &SessionPlan::None, &[], None)
.await .await
.expect("seed structured session"); .expect("seed structured session");
f.structured.insert(session, agent.id, host); f.structured.insert(session, agent.id, host);

View File

@ -232,6 +232,7 @@ impl AgentSessionFactory for FakeFactory {
ctx: &PreparedContext, ctx: &PreparedContext,
_cwd: &ProjectPath, _cwd: &ProjectPath,
session: &SessionPlan, session: &SessionPlan,
_env: &[(String, String)],
sandbox: Option<&domain::SandboxPlan>, sandbox: Option<&domain::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> { ) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
self.starts self.starts

View File

@ -851,6 +851,7 @@ pub trait AgentSessionFactory: Send + Sync {
ctx: &PreparedContext, ctx: &PreparedContext,
cwd: &ProjectPath, cwd: &ProjectPath,
session: &SessionPlan, session: &SessionPlan,
env: &[(String, String)],
sandbox: Option<&crate::sandbox::SandboxPlan>, sandbox: Option<&crate::sandbox::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError>; ) -> Result<Arc<dyn AgentSession>, AgentSessionError>;
} }

View File

@ -246,6 +246,8 @@ pub enum StructuredAdapter {
Claude, Claude,
/// Piloté par `CodexExecSession` (`codex exec` structuré). /// Piloté par `CodexExecSession` (`codex exec` structuré).
Codex, Codex,
/// Piloté par OpenCode en mode process-backed (`opencode run --format json`).
OpenCode,
/// Piloté par l'adapter HTTP OpenAI-compatible (Ollama, llama.cpp, runtime LAN). /// Piloté par l'adapter HTTP OpenAI-compatible (Ollama, llama.cpp, runtime LAN).
OpenAiCompatible, OpenAiCompatible,
} }
@ -264,11 +266,40 @@ impl StructuredAdapter {
match self { match self {
Self::Claude => "claude", Self::Claude => "claude",
Self::Codex => "codex", Self::Codex => "codex",
Self::OpenCode => "opencode",
Self::OpenAiCompatible => "openai-compatible", Self::OpenAiCompatible => "openai-compatible",
} }
} }
} }
/// Configuration déclarative d'un profil OpenCode process-backed.
///
/// OpenCode reste une CLI locale pilotée par process, distincte de
/// [`StructuredAdapter::OpenAiCompatible`] qui est un client HTTP in-process.
/// Le modèle est optionnel : quand il est présent, IdeA l'écrit dans
/// `opencode.json`; quand il est absent, OpenCode applique son propre défaut ou
/// un override explicite porté par la commande utilisateur.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenCodeConfig {
/// Modèle OpenCode complet, par exemple `ollama/qwen3-coder:30b`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
impl OpenCodeConfig {
/// Construit une configuration OpenCode validée.
///
/// # Errors
/// Renvoie [`DomainError::EmptyField`] si `model` est présent mais vide.
pub fn new(model: Option<String>) -> Result<Self, DomainError> {
if let Some(model) = &model {
crate::validation::non_empty(model, "opencode.model")?;
}
Ok(Self { model })
}
}
/// Configuration HTTP d'un serveur de chat OpenAI-compatible. /// Configuration HTTP d'un serveur de chat OpenAI-compatible.
/// ///
/// Pure donnée domaine : l'endpoint est validé syntaxiquement mais jamais contacté, /// Pure donnée domaine : l'endpoint est validé syntaxiquement mais jamais contacté,
@ -417,6 +448,12 @@ pub enum McpConfigStrategy {
/// `target` (ex. `"CODEX_HOME"`). /// `target` (ex. `"CODEX_HOME"`).
home_env: String, home_env: String,
}, },
/// Écrire une configuration OpenCode complète (`opencode.json`) dans le run dir.
/// Ce format porte à la fois le provider Ollama et le serveur MCP local `idea`.
OpenCodeConfig {
/// Chemin relatif sûr du fichier de config OpenCode.
target: String,
},
} }
impl McpConfigStrategy { impl McpConfigStrategy {
@ -469,6 +506,17 @@ impl McpConfigStrategy {
crate::validation::valid_env_var(&home_env)?; crate::validation::valid_env_var(&home_env)?;
Ok(Self::TomlConfigHome { target, home_env }) Ok(Self::TomlConfigHome { target, home_env })
} }
/// Constructeur validé `OpenCodeConfig`.
///
/// # Errors
/// Renvoie [`DomainError::PathNotRelativeSafe`] si `target` est absolu ou
/// contient `..`.
pub fn open_code_config(target: impl Into<String>) -> Result<Self, DomainError> {
let target = target.into();
crate::validation::relative_safe(&target)?;
Ok(Self::OpenCodeConfig { target })
}
} }
/// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI, /// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI,
@ -667,6 +715,11 @@ pub struct AgentProfile {
/// tous les profils historiques et tous les adapters non HTTP. /// tous les profils historiques et tous les adapters non HTTP.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub chat_http: Option<HttpChatConfig>, pub chat_http: Option<HttpChatConfig>,
/// Configuration OpenCode pour [`StructuredAdapter::OpenCode`]. Distincte de
/// [`Self::chat_http`] : OpenCode est un host process MCP natif, pas l'adapter
/// HTTP OpenAI-compatible in-process.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode: Option<OpenCodeConfig>,
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1). /// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel). /// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et /// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
@ -871,6 +924,7 @@ impl AgentProfile {
session, session,
structured_adapter: None, structured_adapter: None,
chat_http: None, chat_http: None,
opencode: None,
mcp: None, mcp: None,
liveness: None, liveness: None,
rate_limit_pattern: None, rate_limit_pattern: None,
@ -896,6 +950,13 @@ impl AgentProfile {
self self
} }
/// Builder : fixe la configuration OpenCode process-backed.
#[must_use]
pub fn with_opencode(mut self, config: OpenCodeConfig) -> Self {
self.opencode = Some(config);
self
}
/// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le /// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans MCP ne l'appellent simplement pas. /// profils sans MCP ne l'appellent simplement pas.
@ -992,6 +1053,10 @@ impl AgentProfile {
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => { (Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => {
true true
} }
(
Some(StructuredAdapter::OpenCode),
Some(McpConfigStrategy::OpenCodeConfig { target }),
) => target == "opencode.json",
(Some(StructuredAdapter::OpenAiCompatible), _) => false, (Some(StructuredAdapter::OpenAiCompatible), _) => false,
_ => false, _ => false,
} }
@ -1069,6 +1134,36 @@ mod mcp_tests {
assert_eq!(back, adapter); assert_eq!(back, adapter);
} }
#[test]
fn opencode_adapter_is_distinct_and_materialises_open_code_config_only() {
let adapter = StructuredAdapter::OpenCode;
assert_eq!(adapter.provider_key(), "opencode");
let json = serde_json::to_string(&adapter).expect("serialise");
assert_eq!(json, "\"openCode\"");
let back: StructuredAdapter = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back, adapter);
let profile = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode(OpenCodeConfig::new(Some("ollama/qwen3-coder:30b".to_owned())).unwrap())
.with_mcp(McpCapability::new(
McpConfigStrategy::open_code_config("opencode.json").unwrap(),
McpTransport::Stdio,
));
assert!(profile.materializes_idea_bridge());
}
#[test]
fn openai_compatible_stays_non_mcp_native_even_with_mcp_declared() {
let profile = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::OpenAiCompatible)
.with_mcp(McpCapability::new(
McpConfigStrategy::open_code_config("opencode.json").unwrap(),
McpTransport::Stdio,
));
assert!(!profile.materializes_idea_bridge());
}
#[test] #[test]
fn http_chat_config_validates_without_io_and_round_trips() { fn http_chat_config_validates_without_io_and_round_trips() {
let config = HttpChatConfig::new( let config = HttpChatConfig::new(

View File

@ -340,6 +340,7 @@ impl AgentSessionFactory for FakeFactory {
_ctx: &PreparedContext, _ctx: &PreparedContext,
_cwd: &ProjectPath, _cwd: &ProjectPath,
_session: &SessionPlan, _session: &SessionPlan,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>, _sandbox: Option<&domain::sandbox::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> { ) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
Ok(Arc::new(FakeSession { Ok(Arc::new(FakeSession {
@ -396,7 +397,7 @@ async fn fake_factory_supports_only_structured_profiles_and_starts() {
}; };
let cwd = ProjectPath::new("/srv/run").unwrap(); let cwd = ProjectPath::new("/srv/run").unwrap();
let session = factory let session = factory
.start(&structured, &ctx, &cwd, &SessionPlan::None, None) .start(&structured, &ctx, &cwd, &SessionPlan::None, &[], None)
.await .await
.expect("factory starts a session"); .expect("factory starts a session");
assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7))); assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7)));

View File

@ -124,6 +124,8 @@ pub struct CodexExecSession {
cwd: String, cwd: String,
/// Project/workspace roots that must be writable in Codex's CLI sandbox. /// Project/workspace roots that must be writable in Codex's CLI sandbox.
writable_roots: Vec<String>, writable_roots: Vec<String>,
/// Variables d'environnement préparées au lancement (ex. `CODEX_HOME` isolé).
env: Vec<(String, String)>,
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant. /// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
conversation_id: Mutex<Option<String>>, conversation_id: Mutex<Option<String>>,
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque /// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
@ -146,6 +148,7 @@ impl CodexExecSession {
cwd: impl Into<String>, cwd: impl Into<String>,
seed_conversation_id: Option<String>, seed_conversation_id: Option<String>,
writable_roots: Vec<String>, writable_roots: Vec<String>,
env: Vec<(String, String)>,
sandbox: Option<SandboxPlan>, sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>, sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
) -> Self { ) -> Self {
@ -154,6 +157,7 @@ impl CodexExecSession {
command: command.into(), command: command.into(),
cwd: cwd.into(), cwd: cwd.into(),
writable_roots, writable_roots,
env,
conversation_id: Mutex::new(seed_conversation_id), conversation_id: Mutex::new(seed_conversation_id),
sandbox, sandbox,
sandbox_enforcer, sandbox_enforcer,
@ -196,7 +200,7 @@ impl CodexExecSession {
command: self.command.clone(), command: self.command.clone(),
args, args,
cwd: self.cwd.clone(), cwd: self.cwd.clone(),
env: Vec::new(), env: self.env.clone(),
stdin: None, stdin: None,
sandbox: self.sandbox.clone(), sandbox: self.sandbox.clone(),
} }

View File

@ -24,6 +24,7 @@ use domain::SessionId;
use super::claude::ClaudeSdkSession; use super::claude::ClaudeSdkSession;
use super::codex::CodexExecSession; use super::codex::CodexExecSession;
use super::openai_compat::OpenAiCompatibleSession; use super::openai_compat::OpenAiCompatibleSession;
use super::opencode::OpenCodeSession;
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot"; const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
const REQUESTER_ARG: &str = "__ideaRequester"; const REQUESTER_ARG: &str = "__ideaRequester";
@ -135,6 +136,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
ctx: &PreparedContext, ctx: &PreparedContext,
cwd: &ProjectPath, cwd: &ProjectPath,
session: &SessionPlan, session: &SessionPlan,
env: &[(String, String)],
sandbox: Option<&SandboxPlan>, sandbox: Option<&SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> { ) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
let adapter = profile.structured_adapter.ok_or_else(|| { let adapter = profile.structured_adapter.ok_or_else(|| {
@ -176,9 +178,19 @@ impl AgentSessionFactory for StructuredSessionFactory {
cwd, cwd,
seed, seed,
vec![ctx.project_root.clone()], vec![ctx.project_root.clone()],
env.to_vec(),
plan, plan,
enforcer, enforcer,
)), )),
StructuredAdapter::OpenCode => Arc::new(OpenCodeSession::new(
id,
profile.command.clone(),
profile.args.clone(),
cwd,
env.to_vec(),
plan,
enforcer,
)?),
StructuredAdapter::OpenAiCompatible => { StructuredAdapter::OpenAiCompatible => {
let config = profile.chat_http.clone().ok_or_else(|| { let config = profile.chat_http.clone().ok_or_else(|| {
AgentSessionError::Start(format!( AgentSessionError::Start(format!(

View File

@ -24,6 +24,7 @@ pub mod codex;
pub mod conformance; pub mod conformance;
pub mod factory; pub mod factory;
pub mod openai_compat; pub mod openai_compat;
pub mod opencode;
pub mod process; pub mod process;
/// Tests bout-en-bout de l'enforcement Landlock sur le chemin structuré (lot LP4-4), /// Tests bout-en-bout de l'enforcement Landlock sur le chemin structuré (lot LP4-4),
@ -36,6 +37,7 @@ pub use codex::CodexExecSession;
pub use conformance::FakeCli; pub use conformance::FakeCli;
pub use factory::StructuredSessionFactory; pub use factory::StructuredSessionFactory;
pub use openai_compat::OpenAiCompatibleSession; pub use openai_compat::OpenAiCompatibleSession;
pub use opencode::OpenCodeSession;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@ -377,6 +379,7 @@ mod tests {
"/", "/",
None, None,
Vec::new(), Vec::new(),
Vec::new(),
None, None,
None, None,
)); ));
@ -468,7 +471,7 @@ mod tests {
let mut expected: HashMap<&str, bool> = HashMap::new(); let mut expected: HashMap<&str, bool> = HashMap::new();
expected.insert("claude", true); expected.insert("claude", true);
expected.insert("codex", true); expected.insert("codex", true);
expected.insert("openai-compatible", true); expected.insert("opencode", true);
expected.insert("gemini", false); expected.insert("gemini", false);
expected.insert("aider", false); expected.insert("aider", false);
@ -502,7 +505,14 @@ mod tests {
// Claude : la session démarre et respecte le contrat via le fake CLI. // Claude : la session démarre et respecte le contrat via le fake CLI.
let claude = structured_profile(StructuredAdapter::Claude, &fake.command()); let claude = structured_profile(StructuredAdapter::Claude, &fake.command());
let session = factory let session = factory
.start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None, None) .start(
&claude,
&prepared_ctx(),
&cwd(),
&SessionPlan::None,
&[],
None,
)
.await .await
.expect("start Claude ok"); .expect("start Claude ok");
let content = drain_final(session.as_ref()).await; let content = drain_final(session.as_ref()).await;
@ -512,7 +522,14 @@ mod tests {
let fake_cx = FakeCli::printing(&codex_script()); let fake_cx = FakeCli::printing(&codex_script());
let codex = structured_profile(StructuredAdapter::Codex, &fake_cx.command()); let codex = structured_profile(StructuredAdapter::Codex, &fake_cx.command());
let session_cx = factory let session_cx = factory
.start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None, None) .start(
&codex,
&prepared_ctx(),
&cwd(),
&SessionPlan::None,
&[],
None,
)
.await .await
.expect("start Codex ok"); .expect("start Codex ok");
let content_cx = drain_final(session_cx.as_ref()).await; let content_cx = drain_final(session_cx.as_ref()).await;
@ -541,6 +558,7 @@ mod tests {
&prepared_ctx(), &prepared_ctx(),
&temp_cwd("factory-openai"), &temp_cwd("factory-openai"),
&SessionPlan::None, &SessionPlan::None,
&[],
None, None,
) )
.await .await
@ -571,7 +589,7 @@ mod tests {
}; };
let session = factory let session = factory
.start(&codex, &ctx, &cwd(), &SessionPlan::None, None) .start(&codex, &ctx, &cwd(), &SessionPlan::None, &[], None)
.await .await
.expect("start Codex ok"); .expect("start Codex ok");
let content = drain_final(session.as_ref()).await; let content = drain_final(session.as_ref()).await;
@ -605,6 +623,7 @@ mod tests {
&SessionPlan::Resume { &SessionPlan::Resume {
conversation_id: "repris-42".to_owned(), conversation_id: "repris-42".to_owned(),
}, },
&[],
None, None,
) )
.await .await
@ -940,6 +959,7 @@ mod tests {
"/", "/",
None, None,
Vec::new(), Vec::new(),
Vec::new(),
None, None,
None, None,
); );
@ -975,6 +995,7 @@ mod tests {
"/", "/",
None, None,
Vec::new(), Vec::new(),
Vec::new(),
None, None,
None, None,
); );
@ -1005,6 +1026,7 @@ mod tests {
"/", "/",
None, None,
Vec::new(), Vec::new(),
Vec::new(),
None, None,
None, None,
); );
@ -1030,6 +1052,7 @@ mod tests {
"/", "/",
None, None,
Vec::new(), Vec::new(),
Vec::new(),
None, None,
None, None,
); );
@ -1056,6 +1079,7 @@ mod tests {
"/", "/",
None, None,
Vec::new(), Vec::new(),
Vec::new(),
None, None,
None, None,
); );
@ -1262,6 +1286,7 @@ mod tests {
"/", "/",
Some("cx-id".to_owned()), Some("cx-id".to_owned()),
Vec::new(), Vec::new(),
Vec::new(),
None, None,
None, None,
); );
@ -1341,6 +1366,7 @@ mod tests {
&SessionPlan::Resume { &SessionPlan::Resume {
conversation_id: "cx-resume".to_owned(), conversation_id: "cx-resume".to_owned(),
}, },
&[],
None, None,
) )
.await .await
@ -1364,6 +1390,7 @@ mod tests {
&SessionPlan::Assign { &SessionPlan::Assign {
conversation_id: "ignored-by-engine".to_owned(), conversation_id: "ignored-by-engine".to_owned(),
}, },
&[],
None, None,
) )
.await .await
@ -1389,7 +1416,7 @@ mod tests {
) )
.expect("profil valide"); .expect("profil valide");
match factory match factory
.start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None, None) .start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None, &[], None)
.await .await
{ {
Err(AgentSessionError::Start(_)) => {} Err(AgentSessionError::Start(_)) => {}
@ -1413,6 +1440,7 @@ mod tests {
"/", "/",
None, None,
Vec::new(), Vec::new(),
Vec::new(),
None, None,
None, None,
)); ));
@ -1526,6 +1554,7 @@ mod tests {
"/", "/",
None, None,
Vec::new(), Vec::new(),
Vec::new(),
None, None,
None, None,
); );
@ -1577,6 +1606,7 @@ mod tests {
"/", "/",
None, None,
vec!["/project/root".to_owned()], vec!["/project/root".to_owned()],
Vec::new(),
None, None,
None, None,
); );
@ -1616,6 +1646,7 @@ mod tests {
"/", "/",
Some("cx-id".to_owned()), Some("cx-id".to_owned()),
vec!["/project/root".to_owned()], vec!["/project/root".to_owned()],
Vec::new(),
None, None,
None, None,
); );

View File

@ -0,0 +1,315 @@
//! [`OpenCodeSession`] — adapter structuré OpenCode + Ollama.
//!
//! 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 <prompt>`. 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<ParsedEvent, AgentSessionError> {
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<Vec<ReplyEvent>, 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<Vec<String>, AgentSessionError> {
let mut out = Vec::new();
let mut cur = String::new();
let mut chars = raw.chars().peekable();
let mut quote: Option<char> = 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<String>,
cwd: String,
env: Vec<(String, String)>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
}
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<String>,
profile_args: Vec<String>,
cwd: impl Into<String>,
env: Vec<(String, String)>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
) -> Result<Self, AgentSessionError> {
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<String> {
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<String> {
None
}
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
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(_)));
}
}

View File

@ -470,7 +470,7 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
let plan = rw_plan(&run_dir); // plan write-only ⇒ reads/exec du fake non gênés let plan = rw_plan(&run_dir); // plan write-only ⇒ reads/exec du fake non gênés
let session = factory let session = factory
.start(&profile, &ctx, &cwd, &SessionPlan::None, Some(&plan)) .start(&profile, &ctx, &cwd, &SessionPlan::None, &[], Some(&plan))
.await .await
.expect("start sandboxé ok"); .expect("start sandboxé ok");

View File

@ -474,6 +474,7 @@ impl AgentSessionFactory for BlockingReplyFactory {
_ctx: &PreparedContext, _ctx: &PreparedContext,
_cwd: &ProjectPath, _cwd: &ProjectPath,
_session: &SessionPlan, _session: &SessionPlan,
_env: &[(String, String)],
_sandbox: Option<&domain::sandbox::SandboxPlan>, _sandbox: Option<&domain::sandbox::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> { ) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
Ok(Arc::new(BlockingReplySession { Ok(Arc::new(BlockingReplySession {

View File

@ -631,12 +631,12 @@ export type InjectionStrategy = ContextInjection["strategy"];
* `StructuredAdapter`, camelCase wire format). Absent (`structuredAdapter` * `StructuredAdapter`, camelCase wire format). Absent (`structuredAdapter`
* omitted) ⇒ the profile is a plain TUI/PTY agent (historical behaviour); * omitted) ⇒ the profile is a plain TUI/PTY agent (historical behaviour);
* present ⇒ the profile is selectable as a structured AI agent: * present ⇒ the profile is selectable as a structured AI agent:
* - `claude` / `codex`: driven by a spawned CLI binary, * - `claude` / `codex` / `openCode`: driven by a spawned CLI binary,
* - `openAiCompatible`: driven by IdeA's native HTTP adapter against an * - `openAiCompatible`: driven by IdeA's native HTTP adapter against an
* OpenAI-compatible chat server (Ollama, llama.cpp, a LAN runtime) — see * OpenAI-compatible chat server (Ollama, llama.cpp, a LAN runtime) — see
* {@link HttpChatConfig}. * {@link HttpChatConfig}.
*/ */
export type StructuredAdapter = "claude" | "codex" | "openAiCompatible"; export type StructuredAdapter = "claude" | "codex" | "openCode" | "openAiCompatible";
/** /**
* HTTP configuration of an OpenAI-compatible chat server (mirror of the backend * HTTP configuration of an OpenAI-compatible chat server (mirror of the backend
@ -662,6 +662,12 @@ export interface HttpChatConfig {
maxToolIterations?: number; maxToolIterations?: number;
} }
/** Configuration for an OpenCode process-backed profile. */
export interface OpenCodeConfig {
/** Full OpenCode model id, for example `ollama/qwen3-coder:30b`. */
model?: string;
}
/** /**
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a * A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
* UUID string; `detect` is the optional detection command line. * UUID string; `detect` is the optional detection command line.
@ -690,6 +696,8 @@ export interface AgentProfile {
* every historical profile and every non-HTTP adapter. * every historical profile and every non-HTTP adapter.
*/ */
chatHttp?: HttpChatConfig; chatHttp?: HttpChatConfig;
/** OpenCode process-backed config. Present for `structuredAdapter: "openCode"`. */
opencode?: OpenCodeConfig;
} }
/** Availability of a candidate profile after detection (mirror of the DTO). */ /** Availability of a candidate profile after detection (mirror of the DTO). */

View File

@ -125,6 +125,9 @@ export function validateProfile(p: AgentProfile): ProfileErrors {
Object.assign(errors, validateHttpChatConfig(p.chatHttp)); Object.assign(errors, validateHttpChatConfig(p.chatHttp));
} }
} }
if (p.structuredAdapter === "openCode" && p.opencode?.model !== undefined) {
if (p.opencode.model.trim().length === 0) errors.model = "Model is required.";
}
return errors; return errors;
} }