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

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

View File

@ -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<String>,
}
impl OpenCodeConfig {
/// Construit une configuration OpenCode validée.
///
/// # Errors
/// Renvoie [`DomainError::EmptyField`] si `model` est présent mais vide.
pub fn new(model: Option<String>) -> Result<Self, DomainError> {
if let Some(model) = &model {
crate::validation::non_empty(model, "opencode.model")?;
}
Ok(Self { model })
}
}
/// Configuration HTTP d'un serveur de chat OpenAI-compatible.
///
/// 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<String>) -> Result<Self, DomainError> {
let target = target.into();
crate::validation::relative_safe(&target)?;
Ok(Self::OpenCodeConfig { target })
}
}
/// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI,
@ -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<HttpChatConfig>,
/// Configuration OpenCode pour [`StructuredAdapter::OpenCode`]. Distincte de
/// [`Self::chat_http`] : OpenCode est un host process MCP natif, pas l'adapter
/// HTTP OpenAI-compatible in-process.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode: Option<OpenCodeConfig>,
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
/// `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(