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

@ -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

View File

@ -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);

View File

@ -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]

View File

@ -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);

View File

@ -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