feat(opencode): remplace le profil Ollama HTTP par OpenCode
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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());
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -101,6 +101,7 @@ impl OpenTicketAssistant {
|
||||
&prepared,
|
||||
&input.project.root,
|
||||
&SessionPlan::None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
|
||||
@ -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<u8>)> {
|
||||
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
|
||||
|
||||
@ -1481,6 +1481,7 @@ impl AgentSessionFactory for CompletionFactory {
|
||||
ctx: &PreparedContext,
|
||||
_cwd: &ProjectPath,
|
||||
_session: &SessionPlan,
|
||||
_env: &[(String, String)],
|
||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||
) -> Result<Arc<dyn AgentSession>, 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<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
self.starts.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -522,6 +522,7 @@ impl AgentSessionFactory for FakeFactory {
|
||||
_ctx: &PreparedContext,
|
||||
_cwd: &ProjectPath,
|
||||
session: &SessionPlan,
|
||||
_env: &[(String, String)],
|
||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||
) -> Result<Arc<dyn AgentSession>, 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);
|
||||
|
||||
@ -232,6 +232,7 @@ impl AgentSessionFactory for FakeFactory {
|
||||
ctx: &PreparedContext,
|
||||
_cwd: &ProjectPath,
|
||||
session: &SessionPlan,
|
||||
_env: &[(String, String)],
|
||||
sandbox: Option<&domain::SandboxPlan>,
|
||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||
self.starts
|
||||
|
||||
Reference in New Issue
Block a user