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::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<AgentProfile> {
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());
}

View File

@ -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<NodeId>,
size: PtySize,
env: &[(String, String)],
sandbox: Option<&SandboxPlan>,
) -> Result<LaunchAgentOutput, AppError> {
// 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_*` (`<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
/// 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 `<base>/<rel>` — 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