From eaba05d27d8c06de2379c03cb9dad259b58f837f Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 9 Jul 2026 15:48:07 +0200 Subject: [PATCH 1/2] feat(opencode): remplace le profil Ollama HTTP par OpenCode Co-Authored-By: Claude Opus 4.8 --- crates/application/src/agent/catalogue.rs | 55 +-- crates/application/src/agent/lifecycle.rs | 133 +++++++- crates/application/src/ticket_assistant.rs | 1 + crates/application/tests/agent_lifecycle.rs | 68 +++- .../application/tests/orchestrator_service.rs | 2 + crates/application/tests/profile_usecases.rs | 25 +- .../application/tests/structured_launch_d3.rs | 3 +- crates/application/tests/ticket_assistant.rs | 1 + crates/domain/src/ports.rs | 1 + crates/domain/src/profile.rs | 95 ++++++ crates/domain/tests/structured_session_d0.rs | 3 +- crates/infrastructure/src/session/codex.rs | 6 +- crates/infrastructure/src/session/factory.rs | 12 + crates/infrastructure/src/session/mod.rs | 41 ++- crates/infrastructure/src/session/opencode.rs | 315 ++++++++++++++++++ .../infrastructure/src/session/sandbox_e2e.rs | 2 +- .../tests/orchestrator_watcher.rs | 1 + frontend/src/domain/index.ts | 12 +- frontend/src/features/first-run/profile.ts | 3 + 19 files changed, 734 insertions(+), 45 deletions(-) create mode 100644 crates/infrastructure/src/session/opencode.rs diff --git a/crates/application/src/agent/catalogue.rs b/crates/application/src/agent/catalogue.rs index 7417f75..66a2be6 100644 --- a/crates/application/src/agent/catalogue.rs +++ b/crates/application/src/agent/catalogue.rs @@ -20,7 +20,7 @@ use domain::ids::ProfileId; use domain::permission::ProjectorKey; use domain::profile::{ - AgentProfile, ContextInjection, HttpChatConfig, McpCapability, McpConfigStrategy, McpTransport, + AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig, StructuredAdapter, }; @@ -97,29 +97,27 @@ pub fn reference_profiles() -> Vec { McpTransport::Stdio, )), AgentProfile::new( - reference_id("ollama-openai-compatible"), - "Ollama / OpenAI-compatible local model", - "openai-compatible", + reference_id("opencode-ollama"), + "OpenCode + Ollama", + "opencode", Vec::new(), ContextInjection::convention_file("AGENTS.md") .expect("AGENTS.md is a valid convention target"), - None, + Some("opencode --version".to_owned()), "{agentRunDir}", None, ) - .expect("OpenAI-compatible reference profile is valid") - .with_structured_adapter(StructuredAdapter::OpenAiCompatible) - .with_chat_http( - HttpChatConfig::new( - "http://localhost:11434/v1", - "qwen2.5-coder", - None, - Some(120_000), - Some(5_000), - Some(HttpChatConfig::DEFAULT_MAX_TOOL_ITERATIONS), - ) - .expect("OpenAI-compatible HTTP config is valid"), - ), + .expect("OpenCode reference profile is valid") + .with_structured_adapter(StructuredAdapter::OpenCode) + .with_opencode( + OpenCodeConfig::new(Some("ollama/qwen3-coder:30b".to_owned())) + .expect("OpenCode 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( reference_id("gemini"), "Gemini CLI", @@ -227,16 +225,23 @@ mod mcp_tests { } #[test] - fn openai_compatible_seed_is_selectable_http_profile_without_mcp() { - let profile = profile("ollama-openai-compatible"); + fn opencode_ollama_seed_replaces_http_ollama_profile() { + let profile = profile("opencode-ollama"); assert_eq!( profile.structured_adapter, - Some(StructuredAdapter::OpenAiCompatible) + Some(StructuredAdapter::OpenCode) ); - let chat = profile.chat_http.as_ref().expect("chatHttp present"); - assert_eq!(chat.endpoint, "http://localhost:11434/v1"); - assert_eq!(chat.model, "qwen2.5-coder"); - assert!(profile.mcp.is_none()); + assert_eq!(profile.command, "opencode"); + assert!(profile.chat_http.is_none()); + assert_eq!( + 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()); } diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index f920d9f..0f116b5 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -1737,6 +1737,7 @@ impl LaunchAgent { &input.project.root, input.node_id, size, + &spec.env, spec.sandbox.as_ref(), ) .await; @@ -1802,12 +1803,13 @@ impl LaunchAgent { root: &ProjectPath, node_id: Option, size: PtySize, + env: &[(String, String)], sandbox: Option<&SandboxPlan>, ) -> Result { // 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. let session = factory - .start(profile, prepared, run_dir, session_plan, sandbox) + .start(profile, prepared, run_dir, session_plan, env, sandbox) .await .map_err(|e| AppError::Process(e.to_string()))?; @@ -2286,6 +2288,39 @@ impl LaunchAgent { let home_dir = parent_dir(run_dir, target); 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 } => { // 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 @@ -2434,6 +2469,98 @@ fn mcp_server_wiring( 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_*` (`_`), 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::>(); + 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 /// segment using a POSIX separator. fn join(base: &ProjectPath, rel: &str) -> String { @@ -2441,6 +2568,10 @@ fn join(base: &ProjectPath, rel: &str) -> String { format!("{b}/{rel}") } +fn parent_rel(rel: &str) -> Option<&str> { + rel.rsplit_once(['/', '\\']).map(|(parent, _)| parent) +} + /// Resolves the **parent directory** (absolute) of `/` — used to point a /// 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 diff --git a/crates/application/src/ticket_assistant.rs b/crates/application/src/ticket_assistant.rs index 1373ee8..c3b3c8b 100644 --- a/crates/application/src/ticket_assistant.rs +++ b/crates/application/src/ticket_assistant.rs @@ -101,6 +101,7 @@ impl OpenTicketAssistant { &prepared, &input.project.root, &SessionPlan::None, + &[], None, ) .await diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 9cd4be8..109cf25 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -34,7 +34,7 @@ use domain::ports::{ RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, }; use domain::profile::{ - AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, + AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig, SessionStrategy, StructuredAdapter, }; 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)) } +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`). fn writes_ending_with(fs: &FakeFs, suffix: &str) -> Vec<(String, Vec)> { 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"); } +#[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] async fn launch_mcp_flag_appends_flag_and_path_to_args() { // Test 5 — Flag { flag: "--mcp-config" } ⇒ spec.args carries the flag followed diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index e5709b8..b8343f9 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -1481,6 +1481,7 @@ impl AgentSessionFactory for CompletionFactory { ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, + _env: &[(String, String)], _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { let id = { @@ -4085,6 +4086,7 @@ impl AgentSessionFactory for CountingFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, + _env: &[(String, String)], _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { self.starts.fetch_add(1, Ordering::SeqCst); diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index b66d988..d7d8bae 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -251,7 +251,7 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() { let out = uc.execute().await.unwrap(); assert!(out.is_first_run); // §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. assert_eq!( out.reference_profiles.len(), @@ -265,7 +265,7 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() { .collect(); assert_eq!( commands, - vec!["claude", "codex", "openai-compatible"], + vec!["claude", "codex", "opencode"], "only structured profiles offered" ); // 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] async fn reference_profiles_use_case_returns_only_selectable() { // §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. let out = ReferenceProfiles::new().execute().await.unwrap(); assert_eq!(out.profiles.len(), 3); 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)); } @@ -353,7 +353,7 @@ fn raw_catalogue_still_has_all_profiles() { .collect(); assert_eq!( 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" ); assert!( - by_command["openai-compatible"].is_selectable(), - "OpenAI-compatible carries a structured adapter ⇒ selectable" + by_command["opencode"].is_selectable(), + "OpenCode carries a structured adapter ⇒ selectable" ); assert!( !by_command["gemini"].is_selectable(), @@ -435,6 +435,12 @@ fn catalogue_has_expected_commands_and_injection() { Some(CODEX_SUBMIT_DELAY_MS), "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!( by_command["gemini"].context_injection, ContextInjection::ConventionFile { @@ -484,6 +490,11 @@ fn catalogue_claude_and_codex_carry_their_structured_adapter() { Some(StructuredAdapter::Codex), "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] diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index 26e3260..a20f2ba 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -522,6 +522,7 @@ impl AgentSessionFactory for FakeFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, session: &SessionPlan, + _env: &[(String, String)], _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { self.starts @@ -1119,7 +1120,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() { let cwd = ProjectPath::new(ROOT).unwrap(); let session = f .factory - .start(&profile, &ctx, &cwd, &SessionPlan::None, None) + .start(&profile, &ctx, &cwd, &SessionPlan::None, &[], None) .await .expect("seed structured session"); f.structured.insert(session, agent.id, host); diff --git a/crates/application/tests/ticket_assistant.rs b/crates/application/tests/ticket_assistant.rs index d5cbfdd..48d9d7d 100644 --- a/crates/application/tests/ticket_assistant.rs +++ b/crates/application/tests/ticket_assistant.rs @@ -232,6 +232,7 @@ impl AgentSessionFactory for FakeFactory { ctx: &PreparedContext, _cwd: &ProjectPath, session: &SessionPlan, + _env: &[(String, String)], sandbox: Option<&domain::SandboxPlan>, ) -> Result, AgentSessionError> { self.starts diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 592eb2e..9de29a6 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -851,6 +851,7 @@ pub trait AgentSessionFactory: Send + Sync { ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, + env: &[(String, String)], sandbox: Option<&crate::sandbox::SandboxPlan>, ) -> Result, AgentSessionError>; } diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index 784b389..f449809 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -246,6 +246,8 @@ pub enum StructuredAdapter { Claude, /// Piloté par `CodexExecSession` (`codex exec` structuré). 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). OpenAiCompatible, } @@ -264,11 +266,40 @@ impl StructuredAdapter { match self { Self::Claude => "claude", Self::Codex => "codex", + Self::OpenCode => "opencode", 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, +} + +impl OpenCodeConfig { + /// Construit une configuration OpenCode validée. + /// + /// # Errors + /// Renvoie [`DomainError::EmptyField`] si `model` est présent mais vide. + pub fn new(model: Option) -> Result { + if let Some(model) = &model { + crate::validation::non_empty(model, "opencode.model")?; + } + Ok(Self { model }) + } +} + /// Configuration HTTP d'un serveur de chat OpenAI-compatible. /// /// Pure donnée domaine : l'endpoint est validé syntaxiquement mais jamais contacté, @@ -417,6 +448,12 @@ pub enum McpConfigStrategy { /// `target` (ex. `"CODEX_HOME"`). 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 { @@ -469,6 +506,17 @@ impl McpConfigStrategy { crate::validation::valid_env_var(&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) -> Result { + 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, @@ -667,6 +715,11 @@ pub struct AgentProfile { /// tous les profils historiques et tous les adapters non HTTP. #[serde(default, skip_serializing_if = "Option::is_none")] pub chat_http: Option, + /// 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, /// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1). /// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel). /// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et @@ -871,6 +924,7 @@ impl AgentProfile { session, structured_adapter: None, chat_http: None, + opencode: None, mcp: None, liveness: None, rate_limit_pattern: None, @@ -896,6 +950,13 @@ impl AgentProfile { 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 /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les /// profils sans MCP ne l'appellent simplement pas. @@ -992,6 +1053,10 @@ impl AgentProfile { (Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => { true } + ( + Some(StructuredAdapter::OpenCode), + Some(McpConfigStrategy::OpenCodeConfig { target }), + ) => target == "opencode.json", (Some(StructuredAdapter::OpenAiCompatible), _) => false, _ => false, } @@ -1069,6 +1134,36 @@ mod mcp_tests { 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] fn http_chat_config_validates_without_io_and_round_trips() { let config = HttpChatConfig::new( diff --git a/crates/domain/tests/structured_session_d0.rs b/crates/domain/tests/structured_session_d0.rs index 28b21b1..5514931 100644 --- a/crates/domain/tests/structured_session_d0.rs +++ b/crates/domain/tests/structured_session_d0.rs @@ -340,6 +340,7 @@ impl AgentSessionFactory for FakeFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, + _env: &[(String, String)], _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { 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 session = factory - .start(&structured, &ctx, &cwd, &SessionPlan::None, None) + .start(&structured, &ctx, &cwd, &SessionPlan::None, &[], None) .await .expect("factory starts a session"); assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7))); diff --git a/crates/infrastructure/src/session/codex.rs b/crates/infrastructure/src/session/codex.rs index b3856f6..981fbc5 100644 --- a/crates/infrastructure/src/session/codex.rs +++ b/crates/infrastructure/src/session/codex.rs @@ -124,6 +124,8 @@ pub struct CodexExecSession { cwd: String, /// Project/workspace roots that must be writable in Codex's CLI sandbox. writable_roots: Vec, + /// 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. conversation_id: Mutex>, /// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque @@ -146,6 +148,7 @@ impl CodexExecSession { cwd: impl Into, seed_conversation_id: Option, writable_roots: Vec, + env: Vec<(String, String)>, sandbox: Option, sandbox_enforcer: Option>, ) -> Self { @@ -154,6 +157,7 @@ impl CodexExecSession { command: command.into(), cwd: cwd.into(), writable_roots, + env, conversation_id: Mutex::new(seed_conversation_id), sandbox, sandbox_enforcer, @@ -196,7 +200,7 @@ impl CodexExecSession { command: self.command.clone(), args, cwd: self.cwd.clone(), - env: Vec::new(), + env: self.env.clone(), stdin: None, sandbox: self.sandbox.clone(), } diff --git a/crates/infrastructure/src/session/factory.rs b/crates/infrastructure/src/session/factory.rs index 798bfe7..dadcfa7 100644 --- a/crates/infrastructure/src/session/factory.rs +++ b/crates/infrastructure/src/session/factory.rs @@ -24,6 +24,7 @@ use domain::SessionId; use super::claude::ClaudeSdkSession; use super::codex::CodexExecSession; use super::openai_compat::OpenAiCompatibleSession; +use super::opencode::OpenCodeSession; const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot"; const REQUESTER_ARG: &str = "__ideaRequester"; @@ -135,6 +136,7 @@ impl AgentSessionFactory for StructuredSessionFactory { ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, + env: &[(String, String)], sandbox: Option<&SandboxPlan>, ) -> Result, AgentSessionError> { let adapter = profile.structured_adapter.ok_or_else(|| { @@ -176,9 +178,19 @@ impl AgentSessionFactory for StructuredSessionFactory { cwd, seed, vec![ctx.project_root.clone()], + env.to_vec(), plan, enforcer, )), + StructuredAdapter::OpenCode => Arc::new(OpenCodeSession::new( + id, + profile.command.clone(), + profile.args.clone(), + cwd, + env.to_vec(), + plan, + enforcer, + )?), StructuredAdapter::OpenAiCompatible => { let config = profile.chat_http.clone().ok_or_else(|| { AgentSessionError::Start(format!( diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index cb24715..28cfead 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -24,6 +24,7 @@ pub mod codex; pub mod conformance; pub mod factory; pub mod openai_compat; +pub mod opencode; pub mod process; /// 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 factory::StructuredSessionFactory; pub use openai_compat::OpenAiCompatibleSession; +pub use opencode::OpenCodeSession; #[cfg(test)] mod tests { @@ -377,6 +379,7 @@ mod tests { "/", None, Vec::new(), + Vec::new(), None, None, )); @@ -468,7 +471,7 @@ mod tests { let mut expected: HashMap<&str, bool> = HashMap::new(); expected.insert("claude", true); expected.insert("codex", true); - expected.insert("openai-compatible", true); + expected.insert("opencode", true); expected.insert("gemini", false); expected.insert("aider", false); @@ -502,7 +505,14 @@ mod tests { // Claude : la session démarre et respecte le contrat via le fake CLI. let claude = structured_profile(StructuredAdapter::Claude, &fake.command()); let session = factory - .start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None, None) + .start( + &claude, + &prepared_ctx(), + &cwd(), + &SessionPlan::None, + &[], + None, + ) .await .expect("start Claude ok"); let content = drain_final(session.as_ref()).await; @@ -512,7 +522,14 @@ mod tests { let fake_cx = FakeCli::printing(&codex_script()); let codex = structured_profile(StructuredAdapter::Codex, &fake_cx.command()); let session_cx = factory - .start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None, None) + .start( + &codex, + &prepared_ctx(), + &cwd(), + &SessionPlan::None, + &[], + None, + ) .await .expect("start Codex ok"); let content_cx = drain_final(session_cx.as_ref()).await; @@ -541,6 +558,7 @@ mod tests { &prepared_ctx(), &temp_cwd("factory-openai"), &SessionPlan::None, + &[], None, ) .await @@ -571,7 +589,7 @@ mod tests { }; let session = factory - .start(&codex, &ctx, &cwd(), &SessionPlan::None, None) + .start(&codex, &ctx, &cwd(), &SessionPlan::None, &[], None) .await .expect("start Codex ok"); let content = drain_final(session.as_ref()).await; @@ -605,6 +623,7 @@ mod tests { &SessionPlan::Resume { conversation_id: "repris-42".to_owned(), }, + &[], None, ) .await @@ -940,6 +959,7 @@ mod tests { "/", None, Vec::new(), + Vec::new(), None, None, ); @@ -975,6 +995,7 @@ mod tests { "/", None, Vec::new(), + Vec::new(), None, None, ); @@ -1005,6 +1026,7 @@ mod tests { "/", None, Vec::new(), + Vec::new(), None, None, ); @@ -1030,6 +1052,7 @@ mod tests { "/", None, Vec::new(), + Vec::new(), None, None, ); @@ -1056,6 +1079,7 @@ mod tests { "/", None, Vec::new(), + Vec::new(), None, None, ); @@ -1262,6 +1286,7 @@ mod tests { "/", Some("cx-id".to_owned()), Vec::new(), + Vec::new(), None, None, ); @@ -1341,6 +1366,7 @@ mod tests { &SessionPlan::Resume { conversation_id: "cx-resume".to_owned(), }, + &[], None, ) .await @@ -1364,6 +1390,7 @@ mod tests { &SessionPlan::Assign { conversation_id: "ignored-by-engine".to_owned(), }, + &[], None, ) .await @@ -1389,7 +1416,7 @@ mod tests { ) .expect("profil valide"); match factory - .start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None, None) + .start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None, &[], None) .await { Err(AgentSessionError::Start(_)) => {} @@ -1413,6 +1440,7 @@ mod tests { "/", None, Vec::new(), + Vec::new(), None, None, )); @@ -1526,6 +1554,7 @@ mod tests { "/", None, Vec::new(), + Vec::new(), None, None, ); @@ -1577,6 +1606,7 @@ mod tests { "/", None, vec!["/project/root".to_owned()], + Vec::new(), None, None, ); @@ -1616,6 +1646,7 @@ mod tests { "/", Some("cx-id".to_owned()), vec!["/project/root".to_owned()], + Vec::new(), None, None, ); diff --git a/crates/infrastructure/src/session/opencode.rs b/crates/infrastructure/src/session/opencode.rs new file mode 100644 index 0000000..2ee3ba2 --- /dev/null +++ b/crates/infrastructure/src/session/opencode.rs @@ -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 `. L'adapter ne connaît que le contrat +//! JSONL minimal observé/cadré : `step_start`, `text`, `step_finish`. + +use std::process::Stdio; +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::Value; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; +use tokio::process::Command; + +use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream}; +use domain::sandbox::{SandboxEnforcer, SandboxPlan}; +use domain::SessionId; + +/// Un événement OpenCode parsé depuis stdout JSONL. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParsedEvent { + /// Début d'étape OpenCode. + StepStart, + /// Fragment texte assistant. + Text(String), + /// Fin d'étape OpenCode. + StepFinish, + /// Evénement JSON valide mais hors contrat minimal. + Ignored, +} + +/// Parse une ligne JSONL OpenCode. +/// +/// # Errors +/// [`AgentSessionError::Decode`] si la ligne non vide n'est pas du JSON valide. +pub fn parse_jsonl_event(line: &str) -> Result { + let trimmed = line.trim(); + if trimmed.is_empty() { + return Ok(ParsedEvent::Ignored); + } + let value: Value = serde_json::from_str(trimmed) + .map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?; + match value.get("type").and_then(Value::as_str) { + Some("step_start") | Some("step.start") => Ok(ParsedEvent::StepStart), + Some("step_finish") | Some("step.finish") => Ok(ParsedEvent::StepFinish), + Some("text") => Ok(ParsedEvent::Text( + value + .get("text") + .or_else(|| value.get("content")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + )), + _ => Ok(ParsedEvent::Ignored), + } +} + +/// Convertit des lignes JSONL OpenCode en événements domaine. +/// +/// Le `Final` est la concaténation ordonnée des événements `text`. Un stdout JSONL +/// valide mais sans texte est une erreur typée : une délégation ne peut pas être +/// considérée comme réussie sans réponse finale capturable. +pub fn parse_jsonl_turn(lines: &[String]) -> Result, AgentSessionError> { + let mut events = Vec::new(); + let mut final_text = String::new(); + for line in lines { + match parse_jsonl_event(line)? { + ParsedEvent::StepStart | ParsedEvent::StepFinish => { + events.push(ReplyEvent::Heartbeat); + } + ParsedEvent::Text(text) => { + if !text.is_empty() { + final_text.push_str(&text); + events.push(ReplyEvent::TextDelta { text }); + } + } + ParsedEvent::Ignored => {} + } + } + if final_text.trim().is_empty() { + return Err(AgentSessionError::Decode( + "OpenCode n'a produit aucun final textuel".to_owned(), + )); + } + events.push(ReplyEvent::Final { + content: final_text, + }); + Ok(events) +} + +/// Découpe une commande utilisateur en argv sans shell implicite. +/// +/// Supporte les guillemets simples/doubles et les antislashs. Les expansions shell, +/// pipes et substitutions ne sont pas interprétés. +pub fn split_command_prefix(raw: &str) -> Result, AgentSessionError> { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut chars = raw.chars().peekable(); + let mut quote: Option = None; + while let Some(ch) = chars.next() { + match (quote, ch) { + (Some(q), c) if c == q => quote = None, + (None, '\'' | '"') => quote = Some(ch), + (_, '\\') => { + if let Some(next) = chars.next() { + cur.push(next); + } else { + cur.push('\\'); + } + } + (None, c) if c.is_whitespace() => { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + } + (_, c) => cur.push(c), + } + } + if quote.is_some() { + return Err(AgentSessionError::Start( + "commande OpenCode invalide: guillemet non fermé".to_owned(), + )); + } + if !cur.is_empty() { + out.push(cur); + } + if out.is_empty() { + return Err(AgentSessionError::Start( + "commande OpenCode vide".to_owned(), + )); + } + Ok(out) +} + +/// Adapter OpenCode process-backed. +pub struct OpenCodeSession { + id: SessionId, + command: String, + prefix_args: Vec, + cwd: String, + env: Vec<(String, String)>, + sandbox: Option, + sandbox_enforcer: Option>, +} + +impl OpenCodeSession { + /// Construit l'adapter. `command_prefix` peut être `opencode`, un chemin absolu, + /// ou un wrapper avec arguments; IdeA ajoute ensuite `run --format json`. + pub fn new( + id: SessionId, + command_prefix: impl Into, + profile_args: Vec, + cwd: impl Into, + env: Vec<(String, String)>, + sandbox: Option, + sandbox_enforcer: Option>, + ) -> Result { + let mut prefix = split_command_prefix(&command_prefix.into())?; + let command = prefix.remove(0); + prefix.extend(profile_args); + Ok(Self { + id, + command, + prefix_args: prefix, + cwd: cwd.into(), + env, + sandbox, + sandbox_enforcer, + }) + } + + fn build_args(&self, prompt: &str) -> Vec { + let mut args = self.prefix_args.clone(); + args.extend([ + "run".to_owned(), + "--format".to_owned(), + "json".to_owned(), + prompt.to_owned(), + ]); + args + } +} + +#[async_trait] +impl AgentSession for OpenCodeSession { + fn id(&self) -> SessionId { + self.id + } + + fn conversation_id(&self) -> Option { + None + } + + async fn send(&self, prompt: &str) -> Result { + let mut cmd = Command::new(&self.command); + cmd.args(self.build_args(prompt)) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if !self.cwd.is_empty() && self.cwd != "/" { + cmd.current_dir(&self.cwd); + } + for (key, value) in &self.env { + cmd.env(key, value); + } + // OpenCode supporte déjà son propre confinement logique; le plan Landlock + // structuré reste réservé aux chemins process génériques existants. + let _ = (&self.sandbox, &self.sandbox_enforcer); + + let mut child = cmd + .spawn() + .map_err(|e| AgentSessionError::Start(format!("{}: {e}", self.command)))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| AgentSessionError::Io("stdout pipe indisponible".to_owned()))?; + let mut stderr_pipe = child + .stderr + .take() + .ok_or_else(|| AgentSessionError::Io("stderr pipe indisponible".to_owned()))?; + + let mut lines = BufReader::new(stdout).lines(); + let mut collected = Vec::new(); + while let Some(line) = lines + .next_line() + .await + .map_err(|e| AgentSessionError::Io(e.to_string()))? + { + collected.push(line); + } + + let mut stderr_bytes = Vec::new(); + stderr_pipe + .read_to_end(&mut stderr_bytes) + .await + .map_err(|e| AgentSessionError::Io(e.to_string()))?; + let status = child + .wait() + .await + .map_err(|e| AgentSessionError::Io(e.to_string()))?; + let stderr = String::from_utf8_lossy(&stderr_bytes); + if stderr.contains("server unavailable") && stderr.contains("key=idea") { + return Err(AgentSessionError::Start( + "serveur MCP OpenCode `idea` indisponible".to_owned(), + )); + } + if !status.success() { + return Err(AgentSessionError::Io(format!( + "OpenCode a quitté avec le statut {}: {}", + status, + stderr.trim() + ))); + } + + let events = parse_jsonl_turn(&collected)?; + Ok(Box::new(events.into_iter())) + } + + async fn shutdown(&self) -> Result<(), AgentSessionError> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_command_prefix_handles_quotes_without_shell() { + assert_eq!( + split_command_prefix(r#""/tmp/my opencode" --flag "two words" 'three words'"#).unwrap(), + vec!["/tmp/my opencode", "--flag", "two words", "three words"] + ); + } + + #[test] + fn parse_jsonl_turn_concatenates_text_and_adds_final() { + let events = parse_jsonl_turn(&[ + r#"{"type":"step_start"}"#.to_owned(), + r#"{"type":"text","text":"hel"}"#.to_owned(), + r#"{"type":"text","text":"lo"}"#.to_owned(), + r#"{"type":"step_finish"}"#.to_owned(), + ]) + .unwrap(); + assert_eq!( + events, + vec![ + ReplyEvent::Heartbeat, + ReplyEvent::TextDelta { + text: "hel".to_owned() + }, + ReplyEvent::TextDelta { + text: "lo".to_owned() + }, + ReplyEvent::Heartbeat, + ReplyEvent::Final { + content: "hello".to_owned() + } + ] + ); + } + + #[test] + fn parse_jsonl_turn_rejects_empty_final() { + let err = parse_jsonl_turn(&[r#"{"type":"step_start"}"#.to_owned()]).unwrap_err(); + assert!(matches!(err, AgentSessionError::Decode(_))); + } + + #[test] + fn parse_jsonl_event_rejects_invalid_json() { + let err = parse_jsonl_event("{nope").unwrap_err(); + assert!(matches!(err, AgentSessionError::Decode(_))); + } +} diff --git a/crates/infrastructure/src/session/sandbox_e2e.rs b/crates/infrastructure/src/session/sandbox_e2e.rs index f1e9556..f1c67b5 100644 --- a/crates/infrastructure/src/session/sandbox_e2e.rs +++ b/crates/infrastructure/src/session/sandbox_e2e.rs @@ -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 session = factory - .start(&profile, &ctx, &cwd, &SessionPlan::None, Some(&plan)) + .start(&profile, &ctx, &cwd, &SessionPlan::None, &[], Some(&plan)) .await .expect("start sandboxé ok"); diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index 621cdf1..f5eeccf 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -474,6 +474,7 @@ impl AgentSessionFactory for BlockingReplyFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, + _env: &[(String, String)], _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { Ok(Arc::new(BlockingReplySession { diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index d4a5945..8eee2e4 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -631,12 +631,12 @@ export type InjectionStrategy = ContextInjection["strategy"]; * `StructuredAdapter`, camelCase wire format). Absent (`structuredAdapter` * omitted) ⇒ the profile is a plain TUI/PTY agent (historical behaviour); * 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 * OpenAI-compatible chat server (Ollama, llama.cpp, a LAN runtime) — see * {@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 @@ -662,6 +662,12 @@ export interface HttpChatConfig { 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 * UUID string; `detect` is the optional detection command line. @@ -690,6 +696,8 @@ export interface AgentProfile { * every historical profile and every non-HTTP adapter. */ chatHttp?: HttpChatConfig; + /** OpenCode process-backed config. Present for `structuredAdapter: "openCode"`. */ + opencode?: OpenCodeConfig; } /** Availability of a candidate profile after detection (mirror of the DTO). */ diff --git a/frontend/src/features/first-run/profile.ts b/frontend/src/features/first-run/profile.ts index 39988a4..17af9e5 100644 --- a/frontend/src/features/first-run/profile.ts +++ b/frontend/src/features/first-run/profile.ts @@ -125,6 +125,9 @@ export function validateProfile(p: AgentProfile): ProfileErrors { 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; } From 4e70631c40ff2e0987d0e7cf28cd94ba43c58b9d Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 11 Jul 2026 00:41:19 +0200 Subject: [PATCH 2/2] feat(opencode): remplace le provider Ollama par llama.cpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le tool-calling local ne fonctionnait jamais via Ollama. Refonte du support local d'OpenCode autour de llama.cpp: profil, catalogue, matérialisation de la config OpenCode et surface first-run alignés sur llama-server (backend + frontend). QA vert (commandes réelles): domain 244, application 81+64, infra 263 (10 échecs = bind-port sandbox identiques sur develop, non-régression), frontend 574, tsc propre. Réserve E2E live non bloquante faute de llama-server joignable. Co-Authored-By: Claude Opus 4.8 --- crates/application/src/agent/catalogue.rs | 36 ++++-- crates/application/src/agent/lifecycle.rs | 56 +++++---- crates/application/tests/agent_lifecycle.rs | 85 ++++++++++++- crates/domain/src/profile.rs | 94 ++++++++++++-- crates/infrastructure/src/session/opencode.rs | 2 +- frontend/src/adapters/mock/index.ts | 20 ++- frontend/src/adapters/mock/profile.test.ts | 8 +- frontend/src/domain/index.ts | 27 +++- .../first-run/FirstRunWizard.test.tsx | 119 +++++++----------- .../src/features/first-run/FirstRunWizard.tsx | 74 ++++++++++- .../src/features/first-run/profile.test.ts | 54 ++++++++ frontend/src/features/first-run/profile.ts | 43 ++++++- 12 files changed, 478 insertions(+), 140 deletions(-) diff --git a/crates/application/src/agent/catalogue.rs b/crates/application/src/agent/catalogue.rs index 66a2be6..7cf7d54 100644 --- a/crates/application/src/agent/catalogue.rs +++ b/crates/application/src/agent/catalogue.rs @@ -97,8 +97,8 @@ pub fn reference_profiles() -> Vec { McpTransport::Stdio, )), AgentProfile::new( - reference_id("opencode-ollama"), - "OpenCode + Ollama", + reference_id("opencode-llamacpp"), + "OpenCode + llama.cpp", "opencode", Vec::new(), ContextInjection::convention_file("AGENTS.md") @@ -110,8 +110,14 @@ pub fn reference_profiles() -> Vec { .expect("OpenCode reference profile is valid") .with_structured_adapter(StructuredAdapter::OpenCode) .with_opencode( - OpenCodeConfig::new(Some("ollama/qwen3-coder:30b".to_owned())) - .expect("OpenCode config is valid"), + OpenCodeConfig::new( + "http://localhost:8080/v1", + Some("sk-no-key".to_owned()), + "qwen3-coder-30b", + None, + None, + ) + .expect("OpenCode config is valid"), ) .with_mcp(McpCapability::new( McpConfigStrategy::open_code_config("opencode.json") @@ -225,8 +231,8 @@ mod mcp_tests { } #[test] - fn opencode_ollama_seed_replaces_http_ollama_profile() { - let profile = profile("opencode-ollama"); + fn opencode_llamacpp_seed_replaces_http_ollama_profile() { + let profile = profile("opencode-llamacpp"); assert_eq!( profile.structured_adapter, Some(StructuredAdapter::OpenCode) @@ -237,8 +243,22 @@ mod mcp_tests { profile .opencode .as_ref() - .and_then(|config| config.model.as_deref()), - Some("ollama/qwen3-coder:30b") + .map(|config| config.model.as_str()), + Some("qwen3-coder-30b") + ); + assert_eq!( + profile + .opencode + .as_ref() + .map(|config| config.base_url.as_str()), + Some("http://localhost:8080/v1") + ); + assert_eq!( + profile + .opencode + .as_ref() + .and_then(|config| config.api_key.as_deref()), + Some("sk-no-key") ); assert!(profile.mcp.is_some()); assert!(profile.materializes_idea_bridge()); diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 0f116b5..4f1ff5e 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -2292,6 +2292,9 @@ impl LaunchAgent { if profile.structured_adapter != Some(StructuredAdapter::OpenCode) { return; } + let Some(opencode) = profile.opencode.as_ref() else { + return; + }; let config_path = join(run_dir, target); let opencode_home = join(run_dir, ".opencode"); let xdg_config = format!("{opencode_home}/config"); @@ -2307,7 +2310,7 @@ impl LaunchAgent { .await; } let body = - opencode_config_json(profile, project_root.as_str(), runtime).to_string(); + opencode_config_json(opencode, project_root.as_str(), runtime).to_string(); let _ = self .fs .write(&RemotePath::new(config_path.clone()), body.as_bytes()) @@ -2474,43 +2477,52 @@ fn mcp_server_wiring( /// The MCP server is named `idea`; OpenCode therefore exposes IdeA tools as /// `idea_idea_*` (`_`), matching the observed contract. fn opencode_config_json( - profile: &AgentProfile, + config: &domain::profile::OpenCodeConfig, project_root: &str, runtime: Option<&McpRuntime>, ) -> serde_json::Value { - let model = profile - .opencode - .as_ref() - .and_then(|config| config.model.as_deref()); + let model = config.model.as_str(); + let opencode_model = format!("llamacpp/{model}"); 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()), + root.insert( + "model".to_owned(), + serde_json::Value::String(opencode_model.clone()), + ); + + let mut options = serde_json::Map::new(); + options.insert( + "baseURL".to_owned(), + serde_json::Value::String(config.base_url.clone()), + ); + if let Some(api_key) = config.api_key.as_ref() { + options.insert( + "apiKey".to_owned(), + serde_json::Value::String(api_key.clone()), ); } 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 }), - ); - } + models.insert( + model.to_owned(), + serde_json::json!({ + "name": opencode_model, + "tool_call": true, + "reasoning": config.reasoning_enabled(), + "attachment": config.attachment_enabled() + }), + ); root.insert( "provider".to_owned(), serde_json::json!({ - "ollama": { + "llamacpp": { "npm": "@ai-sdk/openai-compatible", - "name": "Ollama (local)", - "options": { - "baseURL": "http://localhost:11434/v1" - }, + "name": "llama.cpp", + "options": options, "models": models } }), @@ -2556,7 +2568,7 @@ fn opencode_config_json( ); root.insert( "disabled_providers".to_owned(), - serde_json::json!(["anthropic", "openai", "gemini"]), + serde_json::json!(["anthropic", "openai", "gemini", "ollama"]), ); serde_json::Value::Object(root) } diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 109cf25..1b230f5 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -1587,7 +1587,16 @@ fn profile_with_mcp(id: ProfileId, config: McpConfigStrategy) -> AgentProfile { 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_opencode( + OpenCodeConfig::new( + "http://localhost:8080/v1", + Some("sk-no-key".to_owned()), + "qwen3-coder-30b", + None, + None, + ) + .unwrap(), + ) .with_mcp(McpCapability::new( McpConfigStrategy::open_code_config("opencode.json").unwrap(), McpTransport::Stdio, @@ -1730,10 +1739,34 @@ async fn launch_opencode_writes_isolated_config_and_env() { 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["model"], "llamacpp/qwen3-coder-30b"); assert_eq!( - body["provider"]["ollama"]["options"]["baseURL"], - "http://localhost:11434/v1" + body["provider"]["llamacpp"]["options"]["baseURL"], + "http://localhost:8080/v1" + ); + assert_eq!( + body["provider"]["llamacpp"]["options"]["apiKey"], + "sk-no-key" + ); + assert_eq!( + body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["name"], + "llamacpp/qwen3-coder-30b" + ); + assert_eq!( + body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["tool_call"], + true + ); + assert_eq!( + body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["reasoning"], + true + ); + assert_eq!( + body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["attachment"], + false + ); + assert_eq!( + body["disabled_providers"], + serde_json::json!(["anthropic", "openai", "gemini", "ollama"]) ); assert_eq!(body["mcp"]["idea"]["type"], "local"); assert_eq!(body["mcp"]["idea"]["command"][0], "idea"); @@ -2496,6 +2529,50 @@ async fn launch_with_invalid_uuid_conversation_id_omits_resume_section() { ); } +#[tokio::test] +async fn launch_opencode_omits_api_key_when_profile_has_none() { + let profile = profile(pid(91), ContextInjection::convention_file("AGENTS.md").unwrap()) + .with_structured_adapter(StructuredAdapter::OpenCode) + .with_opencode( + OpenCodeConfig::new( + "http://localhost:8080/v1", + None, + "qwen3-coder-30b", + None, + None, + ) + .unwrap(), + ) + .with_mcp(McpCapability::new( + McpConfigStrategy::open_code_config("opencode.json").unwrap(), + McpTransport::Stdio, + )); + 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 opencode = writes_ending_with(&fs, "/opencode.json"); + assert_eq!(opencode.len(), 1, "one OpenCode config is written"); + let body: serde_json::Value = serde_json::from_slice(&opencode[0].1).unwrap(); + assert_eq!( + body["provider"]["llamacpp"]["options"]["baseURL"], + "http://localhost:8080/v1" + ); + assert!( + body["provider"]["llamacpp"]["options"] + .as_object() + .expect("llamacpp options are an object") + .get("apiKey") + .is_none(), + "apiKey must be omitted, not serialized as null" + ); +} + #[tokio::test] async fn launch_with_store_without_handoff_omits_resume_section() { // Provider wired, valid conversation_id, but the store holds NO handoff for it diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index f449809..2c0a613 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -276,27 +276,68 @@ impl StructuredAdapter { /// /// 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. +/// IdeA matérialise cette configuration dans un `opencode.json` minimal pour un +/// provider llama.cpp compatible OpenAI. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OpenCodeConfig { - /// Modèle OpenCode complet, par exemple `ollama/qwen3-coder:30b`. + /// Endpoint `/v1` du serveur llama.cpp compatible OpenAI. + #[serde(rename = "baseURL")] + pub base_url: String, + /// Clé API optionnelle transmise au provider OpenCode. #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, + pub api_key: Option, + /// Nom nu du modèle servi par llama-server, par exemple `qwen3-coder-30b`. + pub model: String, + /// Active le mode reasoning côté modèle OpenCode. Défaut effectif : `true`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + /// Active les attachments côté modèle OpenCode. Défaut effectif : `false`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attachment: Option, } impl OpenCodeConfig { /// Construit une configuration OpenCode validée. /// /// # Errors - /// Renvoie [`DomainError::EmptyField`] si `model` est présent mais vide. - pub fn new(model: Option) -> Result { - if let Some(model) = &model { - crate::validation::non_empty(model, "opencode.model")?; + /// Renvoie une erreur domaine si `base_url` n'est pas HTTP(S) ou si + /// `base_url`/`model` est vide. + pub fn new( + base_url: impl Into, + api_key: Option, + model: impl Into, + reasoning: Option, + attachment: Option, + ) -> Result { + let base_url = base_url.into(); + let model = model.into(); + crate::validation::non_empty(&base_url, "opencode.baseURL")?; + if !(base_url.starts_with("http://") || base_url.starts_with("https://")) { + return Err(DomainError::Invariant( + "opencode.baseURL must start with http:// or https://".to_owned(), + )); } - Ok(Self { model }) + crate::validation::non_empty(&model, "opencode.model")?; + Ok(Self { + base_url, + api_key, + model, + reasoning, + attachment, + }) + } + + /// Valeur effective de `reasoning` quand le profil ne la porte pas. + #[must_use] + pub fn reasoning_enabled(&self) -> bool { + self.reasoning.unwrap_or(true) + } + + /// Valeur effective de `attachment` quand le profil ne la porte pas. + #[must_use] + pub fn attachment_enabled(&self) -> bool { + self.attachment.unwrap_or(false) } } @@ -1145,7 +1186,16 @@ mod mcp_tests { let profile = profile_without_mcp() .with_structured_adapter(StructuredAdapter::OpenCode) - .with_opencode(OpenCodeConfig::new(Some("ollama/qwen3-coder:30b".to_owned())).unwrap()) + .with_opencode( + OpenCodeConfig::new( + "http://localhost:8080/v1", + Some("sk-no-key".to_owned()), + "qwen3-coder-30b", + None, + None, + ) + .unwrap(), + ) .with_mcp(McpCapability::new( McpConfigStrategy::open_code_config("opencode.json").unwrap(), McpTransport::Stdio, @@ -1153,6 +1203,28 @@ mod mcp_tests { assert!(profile.materializes_idea_bridge()); } + #[test] + fn opencode_config_validates_llamacpp_endpoint_and_model() { + let config = OpenCodeConfig::new( + "https://localhost:8080/v1", + None, + "qwen3-coder-30b", + None, + Some(true), + ) + .unwrap(); + assert_eq!(config.base_url, "https://localhost:8080/v1"); + assert_eq!(config.model, "qwen3-coder-30b"); + assert!(config.reasoning_enabled()); + assert!(config.attachment_enabled()); + + assert!(OpenCodeConfig::new("", None, "qwen3-coder-30b", None, None).is_err()); + assert!( + OpenCodeConfig::new("file:///tmp/v1", None, "qwen3-coder-30b", None, None).is_err() + ); + assert!(OpenCodeConfig::new("http://localhost:8080/v1", None, " ", None, None).is_err()); + } + #[test] fn openai_compatible_stays_non_mcp_native_even_with_mcp_declared() { let profile = profile_without_mcp() diff --git a/crates/infrastructure/src/session/opencode.rs b/crates/infrastructure/src/session/opencode.rs index 2ee3ba2..ded8e91 100644 --- a/crates/infrastructure/src/session/opencode.rs +++ b/crates/infrastructure/src/session/opencode.rs @@ -1,4 +1,4 @@ -//! [`OpenCodeSession`] — adapter structuré OpenCode + Ollama. +//! [`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 diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 6134dac..68c53f3 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1160,20 +1160,18 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [ cwdTemplate: "{projectRoot}", }, { - id: "mock-ollama", - name: "Ollama / OpenAI-compatible local model", - command: "openai-compatible", + id: "mock-opencode", + name: "OpenCode + llama.cpp", + command: "opencode", args: [], contextInjection: { strategy: "conventionFile", target: "AGENTS.md" }, - detect: null, + detect: "opencode --version", cwdTemplate: "{projectRoot}", - structuredAdapter: "openAiCompatible", - chatHttp: { - endpoint: "http://localhost:11434/v1", - model: "qwen2.5-coder", - requestTimeoutMs: 120_000, - connectTimeoutMs: 5_000, - maxToolIterations: 16, + structuredAdapter: "openCode", + opencode: { + baseURL: "http://localhost:8080/v1", + apiKey: "sk-no-key", + model: "qwen3-coder-30b", }, }, ]; diff --git a/frontend/src/adapters/mock/profile.test.ts b/frontend/src/adapters/mock/profile.test.ts index 6d2c361..b9a6f15 100644 --- a/frontend/src/adapters/mock/profile.test.ts +++ b/frontend/src/adapters/mock/profile.test.ts @@ -23,15 +23,15 @@ function customProfile(id: string, command: string): AgentProfile { describe("MockProfileGateway", () => { it("firstRunState is first-run with only the selectable reference profiles", async () => { // §17.3/D7: only structured-drivable profiles are offered (Claude/Codex CLIs - // + the OpenAI-compatible local/LAN adapter, ticket #14); Gemini/Aider are - // filtered out of the selection path server-side. + // + the OpenCode + llama.cpp local adapter); Gemini/Aider are filtered out of + // the selection path server-side. const gw = new MockProfileGateway(); const state = await gw.firstRunState(); expect(state.isFirstRun).toBe(true); expect(state.referenceProfiles.map((p) => p.command)).toEqual([ "claude", "codex", - "openai-compatible", + "opencode", ]); }); @@ -53,7 +53,7 @@ describe("MockProfileGateway", () => { expect(byCommand).toEqual({ claude: true, codex: false, - "openai-compatible": false, + opencode: false, }); }); diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 8eee2e4..1b48846 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -662,10 +662,31 @@ export interface HttpChatConfig { maxToolIterations?: number; } -/** Configuration for an OpenCode process-backed profile. */ +/** + * Configuration for an OpenCode process-backed profile pointed at its own + * llama.cpp (OpenAI-compatible) endpoint. Mirror of the backend `OpenCodeConfig` + * (camelCase wire format: `baseURL`, `apiKey?`, `model`, `reasoning?`, + * `attachment?`). OpenCode is a locally-piloted CLI, distinct from the in-process + * {@link HttpChatConfig} adapter. + */ export interface OpenCodeConfig { - /** Full OpenCode model id, for example `ollama/qwen3-coder:30b`. */ - model?: string; + /** + * OpenAI-compatible base URL served by `llama-server` (http/https), for + * example `http://localhost:8080/v1`. + */ + baseURL: string; + /** + * Optional API key forwarded to the provider — the key *itself*, not an env + * var name (a local llama.cpp typically needs none / a placeholder). Omitted + * when blank (mirrors the backend `skip_serializing_if`). + */ + apiKey?: string; + /** Model name served by `llama-server`, for example `qwen3-coder-30b`. */ + model: string; + /** Enables model reasoning. Effective default: `true`. */ + reasoning?: boolean; + /** Enables attachments. Effective default: `false`. */ + attachment?: boolean; } /** diff --git a/frontend/src/features/first-run/FirstRunWizard.test.tsx b/frontend/src/features/first-run/FirstRunWizard.test.tsx index 6bde8e6..22657f2 100644 --- a/frontend/src/features/first-run/FirstRunWizard.test.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.test.tsx @@ -176,130 +176,103 @@ describe("FirstRunWizard (with MockProfileGateway)", () => { }); }); -describe("FirstRunWizard — OpenAI-compatible local/LAN profile (ticket #14)", () => { - const OLLAMA = "Ollama / OpenAI-compatible local model"; +describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => { + const OPENCODE = "OpenCode + llama.cpp"; - it("offers the local model as a selectable, pre-filled HTTP config row", async () => { + it("offers the local model as a selectable, pre-filled OpenCode config row", async () => { renderWizard(); await waitForLoaded(); - expect(screen.getByText(OLLAMA)).toBeTruthy(); - // The structured HTTP fields are rendered, pre-filled from the reference. + expect(screen.getByText(OPENCODE)).toBeTruthy(); + // The OpenCode fields are rendered, pre-filled from the reference. expect( - (screen.getByLabelText(`${OLLAMA} endpoint`) as HTMLInputElement).value, - ).toBe("http://localhost:11434/v1"); + (screen.getByLabelText(`${OPENCODE} base url`) as HTMLInputElement).value, + ).toBe("http://localhost:8080/v1"); expect( - (screen.getByLabelText(`${OLLAMA} model`) as HTMLInputElement).value, - ).toBe("qwen2.5-coder"); - // Claude/Codex rows must NOT grow HTTP fields (additive, no regression). - expect(screen.queryByLabelText("Claude Code endpoint")).toBeNull(); + (screen.getByLabelText(`${OPENCODE} model`) as HTMLInputElement).value, + ).toBe("qwen3-coder-30b"); + // Claude/Codex rows must NOT grow endpoint fields (additive, no regression). + expect(screen.queryByLabelText("Claude Code base url")).toBeNull(); }); - it("labels the API key field as an env var NAME and flags a raw key", async () => { + it("exposes the API key as a free-form optional field (not an env var name)", async () => { renderWizard(); await waitForLoaded(); - const apiKey = screen.getByLabelText(`${OLLAMA} api key env`) as HTMLInputElement; - // The label/placeholder make the env-var-name intent explicit. - expect(apiKey.placeholder.toLowerCase()).toContain("variable name"); + const apiKey = screen.getByLabelText(`${OPENCODE} api key`) as HTMLInputElement; + expect(apiKey.value).toBe("sk-no-key"); - // A raw secret is not a valid env var identifier ⇒ inline error. + // A raw secret is accepted verbatim (OpenCode carries the key itself) — no + // env-var-name error appears. fireEvent.change(apiKey, { target: { value: "sk-secret-123" } }); + expect(apiKey.value).toBe("sk-secret-123"); expect( - screen.getByText(/valid env var name \(not the key itself\)/i), - ).toBeTruthy(); - - // A proper env var name clears the error. - fireEvent.change(apiKey, { target: { value: "OPENAI_API_KEY" } }); - expect( - screen.queryByText(/valid env var name \(not the key itself\)/i), + screen.queryByText(/valid env var name/i), ).toBeNull(); }); - it("flags an invalid endpoint scheme inline", async () => { + it("flags an invalid base URL scheme inline", async () => { renderWizard(); await waitForLoaded(); - const endpoint = screen.getByLabelText(`${OLLAMA} endpoint`); - fireEvent.change(endpoint, { target: { value: "ftp://oops" } }); + const baseUrl = screen.getByLabelText(`${OPENCODE} base url`); + fireEvent.change(baseUrl, { target: { value: "ftp://oops" } }); expect(screen.getByText(/must start with http:\/\/ or https:\/\//i)).toBeTruthy(); }); - it("exposes the timeout / tool-guard fields pre-filled from the reference", async () => { + it("flags an empty model inline", async () => { renderWizard(); await waitForLoaded(); - expect( - (screen.getByLabelText(`${OLLAMA} request timeout`) as HTMLInputElement).value, - ).toBe("120000"); - expect( - (screen.getByLabelText(`${OLLAMA} connect timeout`) as HTMLInputElement).value, - ).toBe("5000"); - expect( - (screen.getByLabelText(`${OLLAMA} max tool iterations`) as HTMLInputElement) - .value, - ).toBe("16"); + fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), { + target: { value: "" }, + }); + expect(screen.getByText(/model is required/i)).toBeTruthy(); }); - it("persists edited timeouts / tool-guard round-trip into chatHttp", async () => { + it("persists the edited OpenCode config round-trip when selected and saved", async () => { const { profile } = renderWizard(); await waitForLoaded(); - fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`)); - fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), { - target: { value: "90000" }, + // Select the local model and edit its base URL, model and API key. + fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`)); + fireEvent.change(screen.getByLabelText(`${OPENCODE} base url`), { + target: { value: "http://localhost:9090/v1" }, }); - fireEvent.change(screen.getByLabelText(`${OLLAMA} connect timeout`), { - target: { value: "3000" }, + fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), { + target: { value: "qwen3-coder-14b" }, }); - fireEvent.change(screen.getByLabelText(`${OLLAMA} max tool iterations`), { - target: { value: "8" }, + fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), { + target: { value: "sk-local" }, }); fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); await waitFor(async () => { const saved = await profile.listProfiles(); - const ollama = saved.find((p) => p.command === "openai-compatible"); - expect(ollama?.chatHttp?.requestTimeoutMs).toBe(90000); - expect(ollama?.chatHttp?.connectTimeoutMs).toBe(3000); - expect(ollama?.chatHttp?.maxToolIterations).toBe(8); + const opencode = saved.find((p) => p.command === "opencode"); + expect(opencode?.structuredAdapter).toBe("openCode"); + expect(opencode?.opencode?.baseURL).toBe("http://localhost:9090/v1"); + expect(opencode?.opencode?.model).toBe("qwen3-coder-14b"); + expect(opencode?.opencode?.apiKey).toBe("sk-local"); }); }); - it("flags a zero timeout inline (mirror of the backend guard)", async () => { - renderWizard(); - await waitForLoaded(); - - fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), { - target: { value: "0" }, - }); - expect(screen.getByText(/positive integer \(ms\)/i)).toBeTruthy(); - }); - - it("persists the edited HTTP config round-trip when selected and saved", async () => { + it("clearing the API key drops it (optional, omitted when blank)", async () => { const { profile } = renderWizard(); await waitForLoaded(); - // Select the local model and edit its model name. - fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`)); - fireEvent.change(screen.getByLabelText(`${OLLAMA} model`), { - target: { value: "qwen3" }, - }); - fireEvent.change(screen.getByLabelText(`${OLLAMA} api key env`), { - target: { value: "LAN_KEY" }, + fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`)); + fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), { + target: { value: "" }, }); fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); await waitFor(async () => { const saved = await profile.listProfiles(); - const ollama = saved.find((p) => p.command === "openai-compatible"); - expect(ollama?.structuredAdapter).toBe("openAiCompatible"); - expect(ollama?.chatHttp?.model).toBe("qwen3"); - expect(ollama?.chatHttp?.apiKeyEnv).toBe("LAN_KEY"); - // The endpoint round-trips untouched. - expect(ollama?.chatHttp?.endpoint).toBe("http://localhost:11434/v1"); + const opencode = saved.find((p) => p.command === "opencode"); + expect(opencode?.opencode?.apiKey).toBeUndefined(); }); }); }); diff --git a/frontend/src/features/first-run/FirstRunWizard.tsx b/frontend/src/features/first-run/FirstRunWizard.tsx index a84a224..e7ed7a1 100644 --- a/frontend/src/features/first-run/FirstRunWizard.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.tsx @@ -15,11 +15,12 @@ * `./profile`. */ -import type { AgentProfile, HttpChatConfig } from "@/domain"; +import type { AgentProfile, HttpChatConfig, OpenCodeConfig } from "@/domain"; import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared"; import { useFirstRun, type WizardEntry } from "./useFirstRun"; import { defaultHttpChatConfig, + defaultOpenCodeConfig, parseArgs, validateProfile, type ProfileErrors, @@ -190,10 +191,81 @@ function ProfileRow({ {profile.structuredAdapter === "openAiCompatible" && ( )} + + {profile.structuredAdapter === "openCode" && ( + + )} ); } +/** + * The OpenCode + llama.cpp config section: the base URL of the local + * `llama-server` (`/v1`), the model it serves, and an optional API key (the key + * itself — a local llama.cpp usually needs none). Rendered only for a `openCode` + * profile; edits flow back through `onChange` as a patched `opencode`. + */ +function OpenCodeFields({ + profile, + errors, + onChange, +}: { + profile: AgentProfile; + errors: ProfileErrors; + onChange: (p: AgentProfile) => void; +}) { + const opencode = profile.opencode ?? defaultOpenCodeConfig(); + const patch = (next: Partial) => + onChange({ ...profile, opencode: { ...opencode, ...next } }); + + return ( +
+ + Local model (OpenCode + llama.cpp) + + + + + + + +
+ ); +} + /** Parses a number field: blank ⇒ `undefined` (backend applies its default). */ function parseOptInt(raw: string): number | undefined { const trimmed = raw.trim(); diff --git a/frontend/src/features/first-run/profile.test.ts b/frontend/src/features/first-run/profile.test.ts index cc1c91c..19a61b3 100644 --- a/frontend/src/features/first-run/profile.test.ts +++ b/frontend/src/features/first-run/profile.test.ts @@ -9,6 +9,7 @@ import type { AgentProfile } from "@/domain"; import { defaultHttpChatConfig, defaultInjection, + defaultOpenCodeConfig, emptyCustomProfile, isProfileValid, isRelativeSafe, @@ -16,6 +17,7 @@ import { isValidHttpUrl, parseArgs, validateHttpChatConfig, + validateOpenCodeConfig, validateProfile, } from "./profile"; @@ -189,6 +191,58 @@ describe("validateProfile for openAiCompatible profiles", () => { }); }); +describe("validateOpenCodeConfig", () => { + it("a well-formed llama.cpp config is valid", () => { + expect(validateOpenCodeConfig(defaultOpenCodeConfig())).toEqual({}); + }); + it("reports a bad base URL scheme and an empty model", () => { + const errors = validateOpenCodeConfig({ baseURL: "ftp://x", model: " " }); + expect(errors.baseURL).toBeDefined(); + expect(errors.model).toBeDefined(); + }); + it("an optional API key is free-form (no env-var-name constraint)", () => { + // Unlike the openAiCompatible adapter, OpenCode carries the key itself. + expect( + validateOpenCodeConfig({ + baseURL: "http://localhost:8080/v1", + apiKey: "sk-secret-123", + model: "qwen3-coder-30b", + }), + ).toEqual({}); + }); +}); + +describe("validateProfile for openCode profiles", () => { + function opencode(overrides: Partial = {}): AgentProfile { + return base({ + name: "OpenCode + llama.cpp", + command: "opencode", + structuredAdapter: "openCode", + opencode: defaultOpenCodeConfig(), + ...overrides, + }); + } + it("the reference llama.cpp profile is valid", () => { + expect(validateProfile(opencode())).toEqual({}); + expect(isProfileValid(opencode())).toBe(true); + }); + it("surfaces opencode field errors through the profile validator", () => { + const errors = validateProfile( + opencode({ opencode: { baseURL: "nope", model: "" } }), + ); + expect(errors.baseURL).toBeDefined(); + expect(errors.model).toBeDefined(); + expect( + isProfileValid(opencode({ opencode: { baseURL: "nope", model: "m" } })), + ).toBe(false); + }); + it("a missing opencode config on an openCode profile is invalid", () => { + const errors = validateProfile(opencode({ opencode: undefined })); + expect(errors.baseURL).toBeDefined(); + expect(errors.model).toBeDefined(); + }); +}); + describe("defaultInjection", () => { it("produces a sensible default per strategy", () => { expect(defaultInjection("conventionFile")).toEqual({ diff --git a/frontend/src/features/first-run/profile.ts b/frontend/src/features/first-run/profile.ts index 17af9e5..aacdda4 100644 --- a/frontend/src/features/first-run/profile.ts +++ b/frontend/src/features/first-run/profile.ts @@ -9,6 +9,7 @@ import type { ContextInjection, HttpChatConfig, InjectionStrategy, + OpenCodeConfig, } from "@/domain"; /** A field-keyed validation error map (empty ⇒ valid). */ @@ -20,6 +21,7 @@ export type ProfileErrors = Partial< | "flag" | "var" | "endpoint" + | "baseURL" | "model" | "apiKeyEnv" | "requestTimeoutMs" @@ -95,6 +97,23 @@ export function validateHttpChatConfig(c: HttpChatConfig): ProfileErrors { return errors; } +/** + * Validates an {@link OpenCodeConfig} draft the way the backend + * `OpenCodeConfig::new` would (base URL http/https + non-empty, model non-empty; + * the API key is a free-form, optional secret). Returns an empty object when the + * draft is valid. + */ +export function validateOpenCodeConfig(c: OpenCodeConfig): ProfileErrors { + const errors: ProfileErrors = {}; + if (!isValidHttpUrl(c.baseURL)) { + errors.baseURL = "Base URL must start with http:// or https://."; + } + if (c.model.trim().length === 0) { + errors.model = "Model is required."; + } + return errors; +} + /** * Validates a profile draft the way the backend would, so the wizard can surface * errors before any `invoke`. Returns an empty object when the draft is valid. @@ -125,8 +144,15 @@ export function validateProfile(p: AgentProfile): ProfileErrors { 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."; + // An OpenCode profile carries its own llama.cpp endpoint config; the backend + // refuses to persist it unless base URL + model are well-formed, so mirror that. + if (p.structuredAdapter === "openCode") { + if (!p.opencode) { + errors.baseURL = "Base URL must start with http:// or https://."; + errors.model = "Model is required."; + } else { + Object.assign(errors, validateOpenCodeConfig(p.opencode)); + } } return errors; } @@ -168,6 +194,19 @@ export function defaultHttpChatConfig(): HttpChatConfig { }; } +/** + * A sensible default {@link OpenCodeConfig} for a fresh OpenCode profile draft — + * pre-fills a local `llama-server` endpoint so the form starts valid. Mirrors the + * backend reference profile (`http://localhost:8080/v1`, `qwen3-coder-30b`). + */ +export function defaultOpenCodeConfig(): OpenCodeConfig { + return { + baseURL: "http://localhost:8080/v1", + apiKey: "sk-no-key", + model: "qwen3-coder-30b", + }; +} + /** A fresh, empty custom-profile draft (id minted client-side). */ export function emptyCustomProfile(): AgentProfile { return {