Files
IdeA/crates/application/src/agent/catalogue.rs
Blomios 4e70631c40 feat(opencode): remplace le provider Ollama par llama.cpp
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 <noreply@anthropic.com>
2026-07-11 00:41:19 +02:00

304 lines
11 KiB
Rust

//! Reference profile **catalogue** — the pre-filled, *editable* profiles offered
//! by the first-run wizard (CONTEXT §9, ARCHITECTURE §6 `ConfigureProfiles`).
//!
//! These are **data, not domain code**: the catalogue lives in the application
//! layer (a product decision about *which* AIs to suggest), built from the
//! domain's validating constructors. Nothing is imposed — the user picks, edits
//! the pre-filled commands, and may add custom profiles. The single
//! [`domain::ports::AgentRuntime`] adapter consumes whatever profiles result.
//!
//! Reference set (CONTEXT §9):
//! - **Claude Code** — `claude`, context via `CLAUDE.md` (convention file),
//! - **OpenAI Codex CLI** — `codex`, context via `AGENTS.md`,
//! - **Gemini CLI** — `gemini`, context via `GEMINI.md`,
//! - **Aider** — `aider`, context passed as an argument (`--message-file {path}`).
//!
//! The ids are **stable, deterministic UUIDs** (derived from a fixed namespace)
//! so re-deriving the catalogue yields the same id for "claude" every time,
//! making the reference profiles addressable across runs without a registry.
use domain::ids::ProfileId;
use domain::permission::ProjectorKey;
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig,
StructuredAdapter,
};
/// Codex's interactive TUI is sensitive to receiving a large pasted block and the
/// submit key too close together. Keep the default conservative so delegated
/// prompts are actually submitted instead of remaining in the input editor.
pub const CODEX_SUBMIT_DELAY_MS: u32 = 350;
/// A fixed UUID namespace used to derive stable ids for reference profiles.
/// (Random-looking but constant; only its stability matters.)
const REFERENCE_NAMESPACE: uuid::Uuid = uuid::uuid!("6f9b1d2a-7c34-4e58-9a1b-2c3d4e5f6a7b");
/// Derives a stable [`ProfileId`] for a reference profile from its slug.
#[must_use]
fn reference_id(slug: &str) -> ProfileId {
ProfileId::from_uuid(uuid::Uuid::new_v5(&REFERENCE_NAMESPACE, slug.as_bytes()))
}
/// Returns the stable id a reference profile slug maps to (exposed for tests and
/// callers that need to address a reference profile).
#[must_use]
pub fn reference_profile_id(slug: &str) -> ProfileId {
reference_id(slug)
}
/// Builds the pre-filled, editable reference profiles (CONTEXT §9).
///
/// # Panics
/// Never in practice: every literal here satisfies the domain invariants, so the
/// constructors cannot fail; the `expect`s document that.
#[must_use]
pub fn reference_profiles() -> Vec<AgentProfile> {
vec![
AgentProfile::new(
reference_id("claude"),
"Claude Code",
"claude",
Vec::new(),
ContextInjection::convention_file("CLAUDE.md")
.expect("CLAUDE.md is a valid convention target"),
Some("claude --version".to_owned()),
"{agentRunDir}",
None,
)
.expect("claude reference profile is valid")
.with_structured_adapter(StructuredAdapter::Claude)
.with_projector(ProjectorKey::Claude)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json")
.expect(".mcp.json is a valid relative MCP config target"),
McpTransport::Stdio,
)),
AgentProfile::new(
reference_id("codex"),
"OpenAI Codex CLI",
"codex",
Vec::new(),
ContextInjection::convention_file("AGENTS.md")
.expect("AGENTS.md is a valid convention target"),
Some("codex --version".to_owned()),
"{agentRunDir}",
None,
)
.expect("codex reference profile is valid")
.with_structured_adapter(StructuredAdapter::Codex)
.with_projector(ProjectorKey::Codex)
.with_submit_delay_ms(CODEX_SUBMIT_DELAY_MS)
.with_mcp(McpCapability::new(
// Codex lit ses serveurs MCP dans `$CODEX_HOME/config.toml`, pas `.mcp.json` :
// IdeA écrit ce TOML DANS le run dir et pointe `CODEX_HOME` dessus pour
// isoler l'agent du `~/.codex` global (miroir du `.mcp.json` de Claude).
McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
.expect(".codex/config.toml + CODEX_HOME is a valid MCP config target"),
McpTransport::Stdio,
)),
AgentProfile::new(
reference_id("opencode-llamacpp"),
"OpenCode + llama.cpp",
"opencode",
Vec::new(),
ContextInjection::convention_file("AGENTS.md")
.expect("AGENTS.md is a valid convention target"),
Some("opencode --version".to_owned()),
"{agentRunDir}",
None,
)
.expect("OpenCode reference profile is valid")
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode(
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")
.expect("opencode.json is a valid relative config target"),
McpTransport::Stdio,
)),
AgentProfile::new(
reference_id("gemini"),
"Gemini CLI",
"gemini",
Vec::new(),
ContextInjection::convention_file("GEMINI.md")
.expect("GEMINI.md is a valid convention target"),
Some("gemini --version".to_owned()),
"{agentRunDir}",
None,
)
.expect("gemini reference profile is valid"),
AgentProfile::new(
reference_id("aider"),
"Aider",
"aider",
Vec::new(),
ContextInjection::flag("--message-file {path}")
.expect("aider flag template is non-empty"),
Some("aider --version".to_owned()),
"{agentRunDir}",
None,
)
.expect("aider reference profile is valid"),
]
}
/// Returns the **selectable** subset of [`reference_profiles`] — the profiles the
/// first-run wizard and the agent-creation menu are allowed to offer (§17.3,
/// lot D7).
///
/// A profile is selectable iff it can be driven in **structured** mode
/// ([`AgentProfile::is_selectable`] = it carries a `structured_adapter`). Today
/// that is Claude + Codex + OpenAI-compatible; Gemini/Aider stay in
/// [`reference_profiles`] (the data catalogue is untouched) but are **not**
/// proposed for selection. There is no
/// custom-profile entry here either: the selection path offers only profiles we
/// know how to pilot.
///
/// This filter is the single selection gate; `is_selectable` is the same
/// predicate the `AgentSessionFactory` uses to decide it `supports` a profile, so
/// the menu and the runtime can never disagree.
#[must_use]
pub fn selectable_reference_profiles() -> Vec<AgentProfile> {
reference_profiles()
.into_iter()
.filter(AgentProfile::is_selectable)
.collect()
}
#[cfg(test)]
mod mcp_tests {
use super::*;
fn profile(slug: &str) -> AgentProfile {
let id = reference_id(slug);
reference_profiles()
.into_iter()
.find(|p| p.id == id)
.unwrap_or_else(|| panic!("reference profile `{slug}` exists"))
}
#[test]
fn claude_and_codex_expose_mcp_capability() {
for slug in ["claude", "codex"] {
let p = profile(slug);
assert!(
p.mcp.is_some(),
"structured profile `{slug}` must carry an MCP capability"
);
}
}
#[test]
fn claude_mcp_uses_config_file_mcp_json() {
let mcp = profile("claude").mcp.expect("mcp present");
assert_eq!(
mcp.config,
McpConfigStrategy::ConfigFile {
target: ".mcp.json".to_owned()
},
"Claude should declare `.mcp.json`"
);
assert_eq!(mcp.transport, McpTransport::Stdio);
}
#[test]
fn codex_mcp_uses_toml_config_home_codex() {
// Codex lit `$CODEX_HOME/config.toml`, pas `.mcp.json` : le seed doit déclarer
// la stratégie TOML isolée par `CODEX_HOME` (pendant Codex de Claude).
let mcp = profile("codex").mcp.expect("mcp present");
assert_eq!(
mcp.config,
McpConfigStrategy::TomlConfigHome {
target: ".codex/config.toml".to_owned(),
home_env: "CODEX_HOME".to_owned(),
},
"Codex should declare `.codex/config.toml` + CODEX_HOME"
);
assert_eq!(mcp.transport, McpTransport::Stdio);
assert!(
profile("codex").materializes_idea_bridge(),
"the Codex seed must materialise the idea bridge"
);
}
#[test]
fn opencode_llamacpp_seed_replaces_http_ollama_profile() {
let profile = profile("opencode-llamacpp");
assert_eq!(
profile.structured_adapter,
Some(StructuredAdapter::OpenCode)
);
assert_eq!(profile.command, "opencode");
assert!(profile.chat_http.is_none());
assert_eq!(
profile
.opencode
.as_ref()
.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());
assert!(profile.is_selectable());
}
#[test]
fn gemini_and_aider_have_no_mcp_capability() {
for slug in ["gemini", "aider"] {
assert!(
profile(slug).mcp.is_none(),
"non-structured profile `{slug}` must NOT carry MCP (file fallback)"
);
}
}
// -- Lot LP3 : projector (clé du projecteur de permissions par-CLI) ----------
#[test]
fn claude_and_codex_seed_their_projector_key() {
assert_eq!(
profile("claude").projector,
Some(ProjectorKey::Claude),
"the Claude seed must pose the Claude projector"
);
assert_eq!(
profile("codex").projector,
Some(ProjectorKey::Codex),
"the Codex seed must pose the Codex projector"
);
}
#[test]
fn gemini_and_aider_have_no_projector() {
for slug in ["gemini", "aider"] {
assert!(
profile(slug).projector.is_none(),
"non-structured profile `{slug}` must NOT carry a projector (native prompting)"
);
}
}
}