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>
304 lines
11 KiB
Rust
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)"
|
|
);
|
|
}
|
|
}
|
|
}
|