Merge feature/opencode-llamacpp into develop
Remplace le provider Ollama par llama.cpp dans l'intégration OpenCode (tool-calling local). QA vert, réserve E2E live non bloquante. 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::ids::ProfileId;
|
||||||
use domain::permission::ProjectorKey;
|
use domain::permission::ProjectorKey;
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, HttpChatConfig, McpCapability, McpConfigStrategy, McpTransport,
|
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig,
|
||||||
StructuredAdapter,
|
StructuredAdapter,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -97,29 +97,33 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
McpTransport::Stdio,
|
McpTransport::Stdio,
|
||||||
)),
|
)),
|
||||||
AgentProfile::new(
|
AgentProfile::new(
|
||||||
reference_id("ollama-openai-compatible"),
|
reference_id("opencode-llamacpp"),
|
||||||
"Ollama / OpenAI-compatible local model",
|
"OpenCode + llama.cpp",
|
||||||
"openai-compatible",
|
"opencode",
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
ContextInjection::convention_file("AGENTS.md")
|
ContextInjection::convention_file("AGENTS.md")
|
||||||
.expect("AGENTS.md is a valid convention target"),
|
.expect("AGENTS.md is a valid convention target"),
|
||||||
None,
|
Some("opencode --version".to_owned()),
|
||||||
"{agentRunDir}",
|
"{agentRunDir}",
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.expect("OpenAI-compatible reference profile is valid")
|
.expect("OpenCode reference profile is valid")
|
||||||
.with_structured_adapter(StructuredAdapter::OpenAiCompatible)
|
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||||
.with_chat_http(
|
.with_opencode(
|
||||||
HttpChatConfig::new(
|
OpenCodeConfig::new(
|
||||||
"http://localhost:11434/v1",
|
"http://localhost:8080/v1",
|
||||||
"qwen2.5-coder",
|
Some("sk-no-key".to_owned()),
|
||||||
|
"qwen3-coder-30b",
|
||||||
|
None,
|
||||||
None,
|
None,
|
||||||
Some(120_000),
|
|
||||||
Some(5_000),
|
|
||||||
Some(HttpChatConfig::DEFAULT_MAX_TOOL_ITERATIONS),
|
|
||||||
)
|
)
|
||||||
.expect("OpenAI-compatible HTTP config is valid"),
|
.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(
|
AgentProfile::new(
|
||||||
reference_id("gemini"),
|
reference_id("gemini"),
|
||||||
"Gemini CLI",
|
"Gemini CLI",
|
||||||
@ -227,16 +231,37 @@ mod mcp_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_compatible_seed_is_selectable_http_profile_without_mcp() {
|
fn opencode_llamacpp_seed_replaces_http_ollama_profile() {
|
||||||
let profile = profile("ollama-openai-compatible");
|
let profile = profile("opencode-llamacpp");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
profile.structured_adapter,
|
profile.structured_adapter,
|
||||||
Some(StructuredAdapter::OpenAiCompatible)
|
Some(StructuredAdapter::OpenCode)
|
||||||
);
|
);
|
||||||
let chat = profile.chat_http.as_ref().expect("chatHttp present");
|
assert_eq!(profile.command, "opencode");
|
||||||
assert_eq!(chat.endpoint, "http://localhost:11434/v1");
|
assert!(profile.chat_http.is_none());
|
||||||
assert_eq!(chat.model, "qwen2.5-coder");
|
assert_eq!(
|
||||||
assert!(profile.mcp.is_none());
|
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());
|
assert!(profile.is_selectable());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1737,6 +1737,7 @@ impl LaunchAgent {
|
|||||||
&input.project.root,
|
&input.project.root,
|
||||||
input.node_id,
|
input.node_id,
|
||||||
size,
|
size,
|
||||||
|
&spec.env,
|
||||||
spec.sandbox.as_ref(),
|
spec.sandbox.as_ref(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@ -1802,12 +1803,13 @@ impl LaunchAgent {
|
|||||||
root: &ProjectPath,
|
root: &ProjectPath,
|
||||||
node_id: Option<NodeId>,
|
node_id: Option<NodeId>,
|
||||||
size: PtySize,
|
size: PtySize,
|
||||||
|
env: &[(String, String)],
|
||||||
sandbox: Option<&SandboxPlan>,
|
sandbox: Option<&SandboxPlan>,
|
||||||
) -> Result<LaunchAgentOutput, AppError> {
|
) -> Result<LaunchAgentOutput, AppError> {
|
||||||
// Relaie le plan de sandbox OS (lot LP4-4) à la fabrique : `spec.sandbox`,
|
// 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.
|
// déjà compilé (pur, domaine) en step 5d. `None` ⇒ exécution native inchangée.
|
||||||
let session = factory
|
let session = factory
|
||||||
.start(profile, prepared, run_dir, session_plan, sandbox)
|
.start(profile, prepared, run_dir, session_plan, env, sandbox)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Process(e.to_string()))?;
|
.map_err(|e| AppError::Process(e.to_string()))?;
|
||||||
|
|
||||||
@ -2286,6 +2288,42 @@ impl LaunchAgent {
|
|||||||
let home_dir = parent_dir(run_dir, target);
|
let home_dir = parent_dir(run_dir, target);
|
||||||
spec.env.push((home_env.clone(), home_dir));
|
spec.env.push((home_env.clone(), home_dir));
|
||||||
}
|
}
|
||||||
|
domain::profile::McpConfigStrategy::OpenCodeConfig { target } => {
|
||||||
|
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(opencode) = profile.opencode.as_ref() else {
|
||||||
|
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(opencode, 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 } => {
|
domain::profile::McpConfigStrategy::Flag { flag } => {
|
||||||
// Pass the server via a launch flag (e.g. `--mcp-config {path}`). The
|
// 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
|
// config path is the run dir itself (the CLI's cwd), where the server
|
||||||
@ -2434,6 +2472,107 @@ fn mcp_server_wiring(
|
|||||||
domain::McpServerWiring::new(command, args, transport)
|
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(
|
||||||
|
config: &domain::profile::OpenCodeConfig,
|
||||||
|
project_root: &str,
|
||||||
|
runtime: Option<&McpRuntime>,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let model = config.model.as_str();
|
||||||
|
let opencode_model = format!("llamacpp/{model}");
|
||||||
|
let mut root = serde_json::Map::new();
|
||||||
|
root.insert(
|
||||||
|
"$schema".to_owned(),
|
||||||
|
serde_json::Value::String("https://opencode.ai/config.json".to_owned()),
|
||||||
|
);
|
||||||
|
root.insert(
|
||||||
|
"model".to_owned(),
|
||||||
|
serde_json::Value::String(opencode_model.clone()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut options = serde_json::Map::new();
|
||||||
|
options.insert(
|
||||||
|
"baseURL".to_owned(),
|
||||||
|
serde_json::Value::String(config.base_url.clone()),
|
||||||
|
);
|
||||||
|
if let Some(api_key) = config.api_key.as_ref() {
|
||||||
|
options.insert(
|
||||||
|
"apiKey".to_owned(),
|
||||||
|
serde_json::Value::String(api_key.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut models = serde_json::Map::new();
|
||||||
|
models.insert(
|
||||||
|
model.to_owned(),
|
||||||
|
serde_json::json!({
|
||||||
|
"name": opencode_model,
|
||||||
|
"tool_call": true,
|
||||||
|
"reasoning": config.reasoning_enabled(),
|
||||||
|
"attachment": config.attachment_enabled()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
root.insert(
|
||||||
|
"provider".to_owned(),
|
||||||
|
serde_json::json!({
|
||||||
|
"llamacpp": {
|
||||||
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
|
"name": "llama.cpp",
|
||||||
|
"options": options,
|
||||||
|
"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", "ollama"]),
|
||||||
|
);
|
||||||
|
serde_json::Value::Object(root)
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
|
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
|
||||||
/// segment using a POSIX separator.
|
/// segment using a POSIX separator.
|
||||||
fn join(base: &ProjectPath, rel: &str) -> String {
|
fn join(base: &ProjectPath, rel: &str) -> String {
|
||||||
@ -2441,6 +2580,10 @@ fn join(base: &ProjectPath, rel: &str) -> String {
|
|||||||
format!("{b}/{rel}")
|
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
|
/// 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
|
/// 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
|
/// config file (`config.toml`), not the file itself. When `rel` has no separator
|
||||||
|
|||||||
@ -101,6 +101,7 @@ impl OpenTicketAssistant {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&input.project.root,
|
&input.project.root,
|
||||||
&SessionPlan::None,
|
&SessionPlan::None,
|
||||||
|
&[],
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -34,7 +34,7 @@ use domain::ports::{
|
|||||||
RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig,
|
||||||
SessionStrategy, StructuredAdapter,
|
SessionStrategy, StructuredAdapter,
|
||||||
};
|
};
|
||||||
use domain::project::{Project, ProjectPath};
|
use domain::project::{Project, ProjectPath};
|
||||||
@ -1584,6 +1584,25 @@ fn profile_with_mcp(id: ProfileId, config: McpConfigStrategy) -> AgentProfile {
|
|||||||
.with_mcp(McpCapability::new(config, McpTransport::Stdio))
|
.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(
|
||||||
|
"http://localhost:8080/v1",
|
||||||
|
Some("sk-no-key".to_owned()),
|
||||||
|
"qwen3-coder-30b",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.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`).
|
/// 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>)> {
|
fn writes_ending_with(fs: &FakeFs, suffix: &str) -> Vec<(String, Vec<u8>)> {
|
||||||
fs.writes()
|
fs.writes()
|
||||||
@ -1703,6 +1722,86 @@ async fn launch_mcp_config_file_write_failure_is_best_effort() {
|
|||||||
assert_eq!(pty.spawns().len(), 1, "the CLI is still spawned");
|
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"], "llamacpp/qwen3-coder-30b");
|
||||||
|
assert_eq!(
|
||||||
|
body["provider"]["llamacpp"]["options"]["baseURL"],
|
||||||
|
"http://localhost:8080/v1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body["provider"]["llamacpp"]["options"]["apiKey"],
|
||||||
|
"sk-no-key"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["name"],
|
||||||
|
"llamacpp/qwen3-coder-30b"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["tool_call"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["reasoning"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["attachment"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body["disabled_providers"],
|
||||||
|
serde_json::json!(["anthropic", "openai", "gemini", "ollama"])
|
||||||
|
);
|
||||||
|
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]
|
#[tokio::test]
|
||||||
async fn launch_mcp_flag_appends_flag_and_path_to_args() {
|
async fn launch_mcp_flag_appends_flag_and_path_to_args() {
|
||||||
// Test 5 — Flag { flag: "--mcp-config" } ⇒ spec.args carries the flag followed
|
// Test 5 — Flag { flag: "--mcp-config" } ⇒ spec.args carries the flag followed
|
||||||
@ -2430,6 +2529,50 @@ async fn launch_with_invalid_uuid_conversation_id_omits_resume_section() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn launch_opencode_omits_api_key_when_profile_has_none() {
|
||||||
|
let profile = profile(pid(91), ContextInjection::convention_file("AGENTS.md").unwrap())
|
||||||
|
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||||
|
.with_opencode(
|
||||||
|
OpenCodeConfig::new(
|
||||||
|
"http://localhost:8080/v1",
|
||||||
|
None,
|
||||||
|
"qwen3-coder-30b",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.with_mcp(McpCapability::new(
|
||||||
|
McpConfigStrategy::open_code_config("opencode.json").unwrap(),
|
||||||
|
McpTransport::Stdio,
|
||||||
|
));
|
||||||
|
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 opencode = writes_ending_with(&fs, "/opencode.json");
|
||||||
|
assert_eq!(opencode.len(), 1, "one OpenCode config is written");
|
||||||
|
let body: serde_json::Value = serde_json::from_slice(&opencode[0].1).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
body["provider"]["llamacpp"]["options"]["baseURL"],
|
||||||
|
"http://localhost:8080/v1"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body["provider"]["llamacpp"]["options"]
|
||||||
|
.as_object()
|
||||||
|
.expect("llamacpp options are an object")
|
||||||
|
.get("apiKey")
|
||||||
|
.is_none(),
|
||||||
|
"apiKey must be omitted, not serialized as null"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn launch_with_store_without_handoff_omits_resume_section() {
|
async fn launch_with_store_without_handoff_omits_resume_section() {
|
||||||
// Provider wired, valid conversation_id, but the store holds NO handoff for it
|
// Provider wired, valid conversation_id, but the store holds NO handoff for it
|
||||||
|
|||||||
@ -1481,6 +1481,7 @@ impl AgentSessionFactory for CompletionFactory {
|
|||||||
ctx: &PreparedContext,
|
ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
_session: &SessionPlan,
|
_session: &SessionPlan,
|
||||||
|
_env: &[(String, String)],
|
||||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||||
let id = {
|
let id = {
|
||||||
@ -4085,6 +4086,7 @@ impl AgentSessionFactory for CountingFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
_session: &SessionPlan,
|
_session: &SessionPlan,
|
||||||
|
_env: &[(String, String)],
|
||||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||||
self.starts.fetch_add(1, Ordering::SeqCst);
|
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();
|
let out = uc.execute().await.unwrap();
|
||||||
assert!(out.is_first_run);
|
assert!(out.is_first_run);
|
||||||
// §17.3/D7: the wizard is seeded only with the *selectable* (structured-
|
// §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.
|
// catalogue.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
out.reference_profiles.len(),
|
out.reference_profiles.len(),
|
||||||
@ -265,7 +265,7 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() {
|
|||||||
.collect();
|
.collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
commands,
|
commands,
|
||||||
vec!["claude", "codex", "openai-compatible"],
|
vec!["claude", "codex", "opencode"],
|
||||||
"only structured profiles offered"
|
"only structured profiles offered"
|
||||||
);
|
);
|
||||||
// Every seeded profile is selectable (the gate the menu relies on).
|
// 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]
|
#[tokio::test]
|
||||||
async fn reference_profiles_use_case_returns_only_selectable() {
|
async fn reference_profiles_use_case_returns_only_selectable() {
|
||||||
// §17.3/D7: the selection use case exposes only structured-drivable profiles
|
// §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.
|
// `catalogue_*` tests below) but are not offered to selection/creation.
|
||||||
let out = ReferenceProfiles::new().execute().await.unwrap();
|
let out = ReferenceProfiles::new().execute().await.unwrap();
|
||||||
assert_eq!(out.profiles.len(), 3);
|
assert_eq!(out.profiles.len(), 3);
|
||||||
let commands: Vec<&str> = out.profiles.iter().map(|p| p.command.as_str()).collect();
|
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));
|
assert!(out.profiles.iter().all(AgentProfile::is_selectable));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -353,7 +353,7 @@ fn raw_catalogue_still_has_all_profiles() {
|
|||||||
.collect();
|
.collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
commands,
|
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"
|
"Codex carries a structured adapter ⇒ selectable"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
by_command["openai-compatible"].is_selectable(),
|
by_command["opencode"].is_selectable(),
|
||||||
"OpenAI-compatible carries a structured adapter ⇒ selectable"
|
"OpenCode carries a structured adapter ⇒ selectable"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!by_command["gemini"].is_selectable(),
|
!by_command["gemini"].is_selectable(),
|
||||||
@ -435,6 +435,12 @@ fn catalogue_has_expected_commands_and_injection() {
|
|||||||
Some(CODEX_SUBMIT_DELAY_MS),
|
Some(CODEX_SUBMIT_DELAY_MS),
|
||||||
"Codex TUI needs a conservative text→submit delay for delegated prompts"
|
"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!(
|
assert_eq!(
|
||||||
by_command["gemini"].context_injection,
|
by_command["gemini"].context_injection,
|
||||||
ContextInjection::ConventionFile {
|
ContextInjection::ConventionFile {
|
||||||
@ -484,6 +490,11 @@ fn catalogue_claude_and_codex_carry_their_structured_adapter() {
|
|||||||
Some(StructuredAdapter::Codex),
|
Some(StructuredAdapter::Codex),
|
||||||
"Codex reference profile must declare the Codex structured adapter"
|
"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]
|
#[test]
|
||||||
|
|||||||
@ -522,6 +522,7 @@ impl AgentSessionFactory for FakeFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
|
_env: &[(String, String)],
|
||||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||||
self.starts
|
self.starts
|
||||||
@ -1119,7 +1120,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
|||||||
let cwd = ProjectPath::new(ROOT).unwrap();
|
let cwd = ProjectPath::new(ROOT).unwrap();
|
||||||
let session = f
|
let session = f
|
||||||
.factory
|
.factory
|
||||||
.start(&profile, &ctx, &cwd, &SessionPlan::None, None)
|
.start(&profile, &ctx, &cwd, &SessionPlan::None, &[], None)
|
||||||
.await
|
.await
|
||||||
.expect("seed structured session");
|
.expect("seed structured session");
|
||||||
f.structured.insert(session, agent.id, host);
|
f.structured.insert(session, agent.id, host);
|
||||||
|
|||||||
@ -232,6 +232,7 @@ impl AgentSessionFactory for FakeFactory {
|
|||||||
ctx: &PreparedContext,
|
ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
|
_env: &[(String, String)],
|
||||||
sandbox: Option<&domain::SandboxPlan>,
|
sandbox: Option<&domain::SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||||
self.starts
|
self.starts
|
||||||
|
|||||||
@ -851,6 +851,7 @@ pub trait AgentSessionFactory: Send + Sync {
|
|||||||
ctx: &PreparedContext,
|
ctx: &PreparedContext,
|
||||||
cwd: &ProjectPath,
|
cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
|
env: &[(String, String)],
|
||||||
sandbox: Option<&crate::sandbox::SandboxPlan>,
|
sandbox: Option<&crate::sandbox::SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError>;
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -246,6 +246,8 @@ pub enum StructuredAdapter {
|
|||||||
Claude,
|
Claude,
|
||||||
/// Piloté par `CodexExecSession` (`codex exec` structuré).
|
/// Piloté par `CodexExecSession` (`codex exec` structuré).
|
||||||
Codex,
|
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).
|
/// Piloté par l'adapter HTTP OpenAI-compatible (Ollama, llama.cpp, runtime LAN).
|
||||||
OpenAiCompatible,
|
OpenAiCompatible,
|
||||||
}
|
}
|
||||||
@ -264,11 +266,81 @@ impl StructuredAdapter {
|
|||||||
match self {
|
match self {
|
||||||
Self::Claude => "claude",
|
Self::Claude => "claude",
|
||||||
Self::Codex => "codex",
|
Self::Codex => "codex",
|
||||||
|
Self::OpenCode => "opencode",
|
||||||
Self::OpenAiCompatible => "openai-compatible",
|
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.
|
||||||
|
/// IdeA matérialise cette configuration dans un `opencode.json` minimal pour un
|
||||||
|
/// provider llama.cpp compatible OpenAI.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OpenCodeConfig {
|
||||||
|
/// Endpoint `/v1` du serveur llama.cpp compatible OpenAI.
|
||||||
|
#[serde(rename = "baseURL")]
|
||||||
|
pub base_url: String,
|
||||||
|
/// Clé API optionnelle transmise au provider OpenCode.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
/// Nom nu du modèle servi par llama-server, par exemple `qwen3-coder-30b`.
|
||||||
|
pub model: String,
|
||||||
|
/// Active le mode reasoning côté modèle OpenCode. Défaut effectif : `true`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reasoning: Option<bool>,
|
||||||
|
/// Active les attachments côté modèle OpenCode. Défaut effectif : `false`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub attachment: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenCodeConfig {
|
||||||
|
/// Construit une configuration OpenCode validée.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Renvoie une erreur domaine si `base_url` n'est pas HTTP(S) ou si
|
||||||
|
/// `base_url`/`model` est vide.
|
||||||
|
pub fn new(
|
||||||
|
base_url: impl Into<String>,
|
||||||
|
api_key: Option<String>,
|
||||||
|
model: impl Into<String>,
|
||||||
|
reasoning: Option<bool>,
|
||||||
|
attachment: Option<bool>,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
|
let base_url = base_url.into();
|
||||||
|
let model = model.into();
|
||||||
|
crate::validation::non_empty(&base_url, "opencode.baseURL")?;
|
||||||
|
if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
|
||||||
|
return Err(DomainError::Invariant(
|
||||||
|
"opencode.baseURL must start with http:// or https://".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
crate::validation::non_empty(&model, "opencode.model")?;
|
||||||
|
Ok(Self {
|
||||||
|
base_url,
|
||||||
|
api_key,
|
||||||
|
model,
|
||||||
|
reasoning,
|
||||||
|
attachment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valeur effective de `reasoning` quand le profil ne la porte pas.
|
||||||
|
#[must_use]
|
||||||
|
pub fn reasoning_enabled(&self) -> bool {
|
||||||
|
self.reasoning.unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valeur effective de `attachment` quand le profil ne la porte pas.
|
||||||
|
#[must_use]
|
||||||
|
pub fn attachment_enabled(&self) -> bool {
|
||||||
|
self.attachment.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration HTTP d'un serveur de chat OpenAI-compatible.
|
/// Configuration HTTP d'un serveur de chat OpenAI-compatible.
|
||||||
///
|
///
|
||||||
/// Pure donnée domaine : l'endpoint est validé syntaxiquement mais jamais contacté,
|
/// Pure donnée domaine : l'endpoint est validé syntaxiquement mais jamais contacté,
|
||||||
@ -417,6 +489,12 @@ pub enum McpConfigStrategy {
|
|||||||
/// `target` (ex. `"CODEX_HOME"`).
|
/// `target` (ex. `"CODEX_HOME"`).
|
||||||
home_env: String,
|
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 {
|
impl McpConfigStrategy {
|
||||||
@ -469,6 +547,17 @@ impl McpConfigStrategy {
|
|||||||
crate::validation::valid_env_var(&home_env)?;
|
crate::validation::valid_env_var(&home_env)?;
|
||||||
Ok(Self::TomlConfigHome { target, 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,
|
/// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI,
|
||||||
@ -667,6 +756,11 @@ pub struct AgentProfile {
|
|||||||
/// tous les profils historiques et tous les adapters non HTTP.
|
/// tous les profils historiques et tous les adapters non HTTP.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub chat_http: Option<HttpChatConfig>,
|
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).
|
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
|
||||||
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
|
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
|
||||||
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
|
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
|
||||||
@ -871,6 +965,7 @@ impl AgentProfile {
|
|||||||
session,
|
session,
|
||||||
structured_adapter: None,
|
structured_adapter: None,
|
||||||
chat_http: None,
|
chat_http: None,
|
||||||
|
opencode: None,
|
||||||
mcp: None,
|
mcp: None,
|
||||||
liveness: None,
|
liveness: None,
|
||||||
rate_limit_pattern: None,
|
rate_limit_pattern: None,
|
||||||
@ -896,6 +991,13 @@ impl AgentProfile {
|
|||||||
self
|
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
|
/// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
|
||||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||||
/// profils sans MCP ne l'appellent simplement pas.
|
/// profils sans MCP ne l'appellent simplement pas.
|
||||||
@ -992,6 +1094,10 @@ impl AgentProfile {
|
|||||||
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => {
|
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
(
|
||||||
|
Some(StructuredAdapter::OpenCode),
|
||||||
|
Some(McpConfigStrategy::OpenCodeConfig { target }),
|
||||||
|
) => target == "opencode.json",
|
||||||
(Some(StructuredAdapter::OpenAiCompatible), _) => false,
|
(Some(StructuredAdapter::OpenAiCompatible), _) => false,
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
@ -1069,6 +1175,67 @@ mod mcp_tests {
|
|||||||
assert_eq!(back, adapter);
|
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(
|
||||||
|
"http://localhost:8080/v1",
|
||||||
|
Some("sk-no-key".to_owned()),
|
||||||
|
"qwen3-coder-30b",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.with_mcp(McpCapability::new(
|
||||||
|
McpConfigStrategy::open_code_config("opencode.json").unwrap(),
|
||||||
|
McpTransport::Stdio,
|
||||||
|
));
|
||||||
|
assert!(profile.materializes_idea_bridge());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opencode_config_validates_llamacpp_endpoint_and_model() {
|
||||||
|
let config = OpenCodeConfig::new(
|
||||||
|
"https://localhost:8080/v1",
|
||||||
|
None,
|
||||||
|
"qwen3-coder-30b",
|
||||||
|
None,
|
||||||
|
Some(true),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(config.base_url, "https://localhost:8080/v1");
|
||||||
|
assert_eq!(config.model, "qwen3-coder-30b");
|
||||||
|
assert!(config.reasoning_enabled());
|
||||||
|
assert!(config.attachment_enabled());
|
||||||
|
|
||||||
|
assert!(OpenCodeConfig::new("", None, "qwen3-coder-30b", None, None).is_err());
|
||||||
|
assert!(
|
||||||
|
OpenCodeConfig::new("file:///tmp/v1", None, "qwen3-coder-30b", None, None).is_err()
|
||||||
|
);
|
||||||
|
assert!(OpenCodeConfig::new("http://localhost:8080/v1", None, " ", None, None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
fn http_chat_config_validates_without_io_and_round_trips() {
|
fn http_chat_config_validates_without_io_and_round_trips() {
|
||||||
let config = HttpChatConfig::new(
|
let config = HttpChatConfig::new(
|
||||||
|
|||||||
@ -340,6 +340,7 @@ impl AgentSessionFactory for FakeFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
_session: &SessionPlan,
|
_session: &SessionPlan,
|
||||||
|
_env: &[(String, String)],
|
||||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||||
Ok(Arc::new(FakeSession {
|
Ok(Arc::new(FakeSession {
|
||||||
@ -396,7 +397,7 @@ async fn fake_factory_supports_only_structured_profiles_and_starts() {
|
|||||||
};
|
};
|
||||||
let cwd = ProjectPath::new("/srv/run").unwrap();
|
let cwd = ProjectPath::new("/srv/run").unwrap();
|
||||||
let session = factory
|
let session = factory
|
||||||
.start(&structured, &ctx, &cwd, &SessionPlan::None, None)
|
.start(&structured, &ctx, &cwd, &SessionPlan::None, &[], None)
|
||||||
.await
|
.await
|
||||||
.expect("factory starts a session");
|
.expect("factory starts a session");
|
||||||
assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7)));
|
assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7)));
|
||||||
|
|||||||
@ -124,6 +124,8 @@ pub struct CodexExecSession {
|
|||||||
cwd: String,
|
cwd: String,
|
||||||
/// Project/workspace roots that must be writable in Codex's CLI sandbox.
|
/// Project/workspace roots that must be writable in Codex's CLI sandbox.
|
||||||
writable_roots: Vec<String>,
|
writable_roots: Vec<String>,
|
||||||
|
/// Variables d'environnement préparées au lancement (ex. `CODEX_HOME` isolé).
|
||||||
|
env: Vec<(String, String)>,
|
||||||
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
|
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
|
||||||
conversation_id: Mutex<Option<String>>,
|
conversation_id: Mutex<Option<String>>,
|
||||||
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
|
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
|
||||||
@ -146,6 +148,7 @@ impl CodexExecSession {
|
|||||||
cwd: impl Into<String>,
|
cwd: impl Into<String>,
|
||||||
seed_conversation_id: Option<String>,
|
seed_conversation_id: Option<String>,
|
||||||
writable_roots: Vec<String>,
|
writable_roots: Vec<String>,
|
||||||
|
env: Vec<(String, String)>,
|
||||||
sandbox: Option<SandboxPlan>,
|
sandbox: Option<SandboxPlan>,
|
||||||
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@ -154,6 +157,7 @@ impl CodexExecSession {
|
|||||||
command: command.into(),
|
command: command.into(),
|
||||||
cwd: cwd.into(),
|
cwd: cwd.into(),
|
||||||
writable_roots,
|
writable_roots,
|
||||||
|
env,
|
||||||
conversation_id: Mutex::new(seed_conversation_id),
|
conversation_id: Mutex::new(seed_conversation_id),
|
||||||
sandbox,
|
sandbox,
|
||||||
sandbox_enforcer,
|
sandbox_enforcer,
|
||||||
@ -196,7 +200,7 @@ impl CodexExecSession {
|
|||||||
command: self.command.clone(),
|
command: self.command.clone(),
|
||||||
args,
|
args,
|
||||||
cwd: self.cwd.clone(),
|
cwd: self.cwd.clone(),
|
||||||
env: Vec::new(),
|
env: self.env.clone(),
|
||||||
stdin: None,
|
stdin: None,
|
||||||
sandbox: self.sandbox.clone(),
|
sandbox: self.sandbox.clone(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ use domain::SessionId;
|
|||||||
use super::claude::ClaudeSdkSession;
|
use super::claude::ClaudeSdkSession;
|
||||||
use super::codex::CodexExecSession;
|
use super::codex::CodexExecSession;
|
||||||
use super::openai_compat::OpenAiCompatibleSession;
|
use super::openai_compat::OpenAiCompatibleSession;
|
||||||
|
use super::opencode::OpenCodeSession;
|
||||||
|
|
||||||
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
|
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
|
||||||
const REQUESTER_ARG: &str = "__ideaRequester";
|
const REQUESTER_ARG: &str = "__ideaRequester";
|
||||||
@ -135,6 +136,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
|||||||
ctx: &PreparedContext,
|
ctx: &PreparedContext,
|
||||||
cwd: &ProjectPath,
|
cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
|
env: &[(String, String)],
|
||||||
sandbox: Option<&SandboxPlan>,
|
sandbox: Option<&SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||||
let adapter = profile.structured_adapter.ok_or_else(|| {
|
let adapter = profile.structured_adapter.ok_or_else(|| {
|
||||||
@ -176,9 +178,19 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
|||||||
cwd,
|
cwd,
|
||||||
seed,
|
seed,
|
||||||
vec![ctx.project_root.clone()],
|
vec![ctx.project_root.clone()],
|
||||||
|
env.to_vec(),
|
||||||
plan,
|
plan,
|
||||||
enforcer,
|
enforcer,
|
||||||
)),
|
)),
|
||||||
|
StructuredAdapter::OpenCode => Arc::new(OpenCodeSession::new(
|
||||||
|
id,
|
||||||
|
profile.command.clone(),
|
||||||
|
profile.args.clone(),
|
||||||
|
cwd,
|
||||||
|
env.to_vec(),
|
||||||
|
plan,
|
||||||
|
enforcer,
|
||||||
|
)?),
|
||||||
StructuredAdapter::OpenAiCompatible => {
|
StructuredAdapter::OpenAiCompatible => {
|
||||||
let config = profile.chat_http.clone().ok_or_else(|| {
|
let config = profile.chat_http.clone().ok_or_else(|| {
|
||||||
AgentSessionError::Start(format!(
|
AgentSessionError::Start(format!(
|
||||||
|
|||||||
@ -24,6 +24,7 @@ pub mod codex;
|
|||||||
pub mod conformance;
|
pub mod conformance;
|
||||||
pub mod factory;
|
pub mod factory;
|
||||||
pub mod openai_compat;
|
pub mod openai_compat;
|
||||||
|
pub mod opencode;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
|
|
||||||
/// Tests bout-en-bout de l'enforcement Landlock sur le chemin structuré (lot LP4-4),
|
/// Tests bout-en-bout de l'enforcement Landlock sur le chemin structuré (lot LP4-4),
|
||||||
@ -36,6 +37,7 @@ pub use codex::CodexExecSession;
|
|||||||
pub use conformance::FakeCli;
|
pub use conformance::FakeCli;
|
||||||
pub use factory::StructuredSessionFactory;
|
pub use factory::StructuredSessionFactory;
|
||||||
pub use openai_compat::OpenAiCompatibleSession;
|
pub use openai_compat::OpenAiCompatibleSession;
|
||||||
|
pub use opencode::OpenCodeSession;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@ -377,6 +379,7 @@ mod tests {
|
|||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
@ -468,7 +471,7 @@ mod tests {
|
|||||||
let mut expected: HashMap<&str, bool> = HashMap::new();
|
let mut expected: HashMap<&str, bool> = HashMap::new();
|
||||||
expected.insert("claude", true);
|
expected.insert("claude", true);
|
||||||
expected.insert("codex", true);
|
expected.insert("codex", true);
|
||||||
expected.insert("openai-compatible", true);
|
expected.insert("opencode", true);
|
||||||
expected.insert("gemini", false);
|
expected.insert("gemini", false);
|
||||||
expected.insert("aider", false);
|
expected.insert("aider", false);
|
||||||
|
|
||||||
@ -502,7 +505,14 @@ mod tests {
|
|||||||
// Claude : la session démarre et respecte le contrat via le fake CLI.
|
// Claude : la session démarre et respecte le contrat via le fake CLI.
|
||||||
let claude = structured_profile(StructuredAdapter::Claude, &fake.command());
|
let claude = structured_profile(StructuredAdapter::Claude, &fake.command());
|
||||||
let session = factory
|
let session = factory
|
||||||
.start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
|
.start(
|
||||||
|
&claude,
|
||||||
|
&prepared_ctx(),
|
||||||
|
&cwd(),
|
||||||
|
&SessionPlan::None,
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("start Claude ok");
|
.expect("start Claude ok");
|
||||||
let content = drain_final(session.as_ref()).await;
|
let content = drain_final(session.as_ref()).await;
|
||||||
@ -512,7 +522,14 @@ mod tests {
|
|||||||
let fake_cx = FakeCli::printing(&codex_script());
|
let fake_cx = FakeCli::printing(&codex_script());
|
||||||
let codex = structured_profile(StructuredAdapter::Codex, &fake_cx.command());
|
let codex = structured_profile(StructuredAdapter::Codex, &fake_cx.command());
|
||||||
let session_cx = factory
|
let session_cx = factory
|
||||||
.start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
|
.start(
|
||||||
|
&codex,
|
||||||
|
&prepared_ctx(),
|
||||||
|
&cwd(),
|
||||||
|
&SessionPlan::None,
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("start Codex ok");
|
.expect("start Codex ok");
|
||||||
let content_cx = drain_final(session_cx.as_ref()).await;
|
let content_cx = drain_final(session_cx.as_ref()).await;
|
||||||
@ -541,6 +558,7 @@ mod tests {
|
|||||||
&prepared_ctx(),
|
&prepared_ctx(),
|
||||||
&temp_cwd("factory-openai"),
|
&temp_cwd("factory-openai"),
|
||||||
&SessionPlan::None,
|
&SessionPlan::None,
|
||||||
|
&[],
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -571,7 +589,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let session = factory
|
let session = factory
|
||||||
.start(&codex, &ctx, &cwd(), &SessionPlan::None, None)
|
.start(&codex, &ctx, &cwd(), &SessionPlan::None, &[], None)
|
||||||
.await
|
.await
|
||||||
.expect("start Codex ok");
|
.expect("start Codex ok");
|
||||||
let content = drain_final(session.as_ref()).await;
|
let content = drain_final(session.as_ref()).await;
|
||||||
@ -605,6 +623,7 @@ mod tests {
|
|||||||
&SessionPlan::Resume {
|
&SessionPlan::Resume {
|
||||||
conversation_id: "repris-42".to_owned(),
|
conversation_id: "repris-42".to_owned(),
|
||||||
},
|
},
|
||||||
|
&[],
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -940,6 +959,7 @@ mod tests {
|
|||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@ -975,6 +995,7 @@ mod tests {
|
|||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@ -1005,6 +1026,7 @@ mod tests {
|
|||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@ -1030,6 +1052,7 @@ mod tests {
|
|||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@ -1056,6 +1079,7 @@ mod tests {
|
|||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@ -1262,6 +1286,7 @@ mod tests {
|
|||||||
"/",
|
"/",
|
||||||
Some("cx-id".to_owned()),
|
Some("cx-id".to_owned()),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@ -1341,6 +1366,7 @@ mod tests {
|
|||||||
&SessionPlan::Resume {
|
&SessionPlan::Resume {
|
||||||
conversation_id: "cx-resume".to_owned(),
|
conversation_id: "cx-resume".to_owned(),
|
||||||
},
|
},
|
||||||
|
&[],
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -1364,6 +1390,7 @@ mod tests {
|
|||||||
&SessionPlan::Assign {
|
&SessionPlan::Assign {
|
||||||
conversation_id: "ignored-by-engine".to_owned(),
|
conversation_id: "ignored-by-engine".to_owned(),
|
||||||
},
|
},
|
||||||
|
&[],
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -1389,7 +1416,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("profil valide");
|
.expect("profil valide");
|
||||||
match factory
|
match factory
|
||||||
.start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
|
.start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None, &[], None)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Err(AgentSessionError::Start(_)) => {}
|
Err(AgentSessionError::Start(_)) => {}
|
||||||
@ -1413,6 +1440,7 @@ mod tests {
|
|||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
@ -1526,6 +1554,7 @@ mod tests {
|
|||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@ -1577,6 +1606,7 @@ mod tests {
|
|||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
vec!["/project/root".to_owned()],
|
vec!["/project/root".to_owned()],
|
||||||
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@ -1616,6 +1646,7 @@ mod tests {
|
|||||||
"/",
|
"/",
|
||||||
Some("cx-id".to_owned()),
|
Some("cx-id".to_owned()),
|
||||||
vec!["/project/root".to_owned()],
|
vec!["/project/root".to_owned()],
|
||||||
|
Vec::new(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|||||||
315
crates/infrastructure/src/session/opencode.rs
Normal file
315
crates/infrastructure/src/session/opencode.rs
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
//! [`OpenCodeSession`] — adapter structuré OpenCode + llama.cpp.
|
||||||
|
//!
|
||||||
|
//! OpenCode est piloté comme host process local : IdeA génère un `opencode.json`
|
||||||
|
//! isolé dans le run dir, OpenCode lance le bridge MCP `idea`, puis chaque tour est
|
||||||
|
//! un `opencode run --format json <prompt>`. L'adapter ne connaît que le contrat
|
||||||
|
//! JSONL minimal observé/cadré : `step_start`, `text`, `step_finish`.
|
||||||
|
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
||||||
|
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||||
|
use domain::SessionId;
|
||||||
|
|
||||||
|
/// Un événement OpenCode parsé depuis stdout JSONL.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ParsedEvent {
|
||||||
|
/// Début d'étape OpenCode.
|
||||||
|
StepStart,
|
||||||
|
/// Fragment texte assistant.
|
||||||
|
Text(String),
|
||||||
|
/// Fin d'étape OpenCode.
|
||||||
|
StepFinish,
|
||||||
|
/// Evénement JSON valide mais hors contrat minimal.
|
||||||
|
Ignored,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse une ligne JSONL OpenCode.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AgentSessionError::Decode`] si la ligne non vide n'est pas du JSON valide.
|
||||||
|
pub fn parse_jsonl_event(line: &str) -> Result<ParsedEvent, AgentSessionError> {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(ParsedEvent::Ignored);
|
||||||
|
}
|
||||||
|
let value: Value = serde_json::from_str(trimmed)
|
||||||
|
.map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?;
|
||||||
|
match value.get("type").and_then(Value::as_str) {
|
||||||
|
Some("step_start") | Some("step.start") => Ok(ParsedEvent::StepStart),
|
||||||
|
Some("step_finish") | Some("step.finish") => Ok(ParsedEvent::StepFinish),
|
||||||
|
Some("text") => Ok(ParsedEvent::Text(
|
||||||
|
value
|
||||||
|
.get("text")
|
||||||
|
.or_else(|| value.get("content"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_owned(),
|
||||||
|
)),
|
||||||
|
_ => Ok(ParsedEvent::Ignored),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit des lignes JSONL OpenCode en événements domaine.
|
||||||
|
///
|
||||||
|
/// Le `Final` est la concaténation ordonnée des événements `text`. Un stdout JSONL
|
||||||
|
/// valide mais sans texte est une erreur typée : une délégation ne peut pas être
|
||||||
|
/// considérée comme réussie sans réponse finale capturable.
|
||||||
|
pub fn parse_jsonl_turn(lines: &[String]) -> Result<Vec<ReplyEvent>, AgentSessionError> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let mut final_text = String::new();
|
||||||
|
for line in lines {
|
||||||
|
match parse_jsonl_event(line)? {
|
||||||
|
ParsedEvent::StepStart | ParsedEvent::StepFinish => {
|
||||||
|
events.push(ReplyEvent::Heartbeat);
|
||||||
|
}
|
||||||
|
ParsedEvent::Text(text) => {
|
||||||
|
if !text.is_empty() {
|
||||||
|
final_text.push_str(&text);
|
||||||
|
events.push(ReplyEvent::TextDelta { text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ParsedEvent::Ignored => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if final_text.trim().is_empty() {
|
||||||
|
return Err(AgentSessionError::Decode(
|
||||||
|
"OpenCode n'a produit aucun final textuel".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
events.push(ReplyEvent::Final {
|
||||||
|
content: final_text,
|
||||||
|
});
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Découpe une commande utilisateur en argv sans shell implicite.
|
||||||
|
///
|
||||||
|
/// Supporte les guillemets simples/doubles et les antislashs. Les expansions shell,
|
||||||
|
/// pipes et substitutions ne sont pas interprétés.
|
||||||
|
pub fn split_command_prefix(raw: &str) -> Result<Vec<String>, AgentSessionError> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut cur = String::new();
|
||||||
|
let mut chars = raw.chars().peekable();
|
||||||
|
let mut quote: Option<char> = None;
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
match (quote, ch) {
|
||||||
|
(Some(q), c) if c == q => quote = None,
|
||||||
|
(None, '\'' | '"') => quote = Some(ch),
|
||||||
|
(_, '\\') => {
|
||||||
|
if let Some(next) = chars.next() {
|
||||||
|
cur.push(next);
|
||||||
|
} else {
|
||||||
|
cur.push('\\');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, c) if c.is_whitespace() => {
|
||||||
|
if !cur.is_empty() {
|
||||||
|
out.push(std::mem::take(&mut cur));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(_, c) => cur.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if quote.is_some() {
|
||||||
|
return Err(AgentSessionError::Start(
|
||||||
|
"commande OpenCode invalide: guillemet non fermé".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !cur.is_empty() {
|
||||||
|
out.push(cur);
|
||||||
|
}
|
||||||
|
if out.is_empty() {
|
||||||
|
return Err(AgentSessionError::Start(
|
||||||
|
"commande OpenCode vide".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adapter OpenCode process-backed.
|
||||||
|
pub struct OpenCodeSession {
|
||||||
|
id: SessionId,
|
||||||
|
command: String,
|
||||||
|
prefix_args: Vec<String>,
|
||||||
|
cwd: String,
|
||||||
|
env: Vec<(String, String)>,
|
||||||
|
sandbox: Option<SandboxPlan>,
|
||||||
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenCodeSession {
|
||||||
|
/// Construit l'adapter. `command_prefix` peut être `opencode`, un chemin absolu,
|
||||||
|
/// ou un wrapper avec arguments; IdeA ajoute ensuite `run --format json`.
|
||||||
|
pub fn new(
|
||||||
|
id: SessionId,
|
||||||
|
command_prefix: impl Into<String>,
|
||||||
|
profile_args: Vec<String>,
|
||||||
|
cwd: impl Into<String>,
|
||||||
|
env: Vec<(String, String)>,
|
||||||
|
sandbox: Option<SandboxPlan>,
|
||||||
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||||
|
) -> Result<Self, AgentSessionError> {
|
||||||
|
let mut prefix = split_command_prefix(&command_prefix.into())?;
|
||||||
|
let command = prefix.remove(0);
|
||||||
|
prefix.extend(profile_args);
|
||||||
|
Ok(Self {
|
||||||
|
id,
|
||||||
|
command,
|
||||||
|
prefix_args: prefix,
|
||||||
|
cwd: cwd.into(),
|
||||||
|
env,
|
||||||
|
sandbox,
|
||||||
|
sandbox_enforcer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_args(&self, prompt: &str) -> Vec<String> {
|
||||||
|
let mut args = self.prefix_args.clone();
|
||||||
|
args.extend([
|
||||||
|
"run".to_owned(),
|
||||||
|
"--format".to_owned(),
|
||||||
|
"json".to_owned(),
|
||||||
|
prompt.to_owned(),
|
||||||
|
]);
|
||||||
|
args
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AgentSession for OpenCodeSession {
|
||||||
|
fn id(&self) -> SessionId {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conversation_id(&self) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||||
|
let mut cmd = Command::new(&self.command);
|
||||||
|
cmd.args(self.build_args(prompt))
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
if !self.cwd.is_empty() && self.cwd != "/" {
|
||||||
|
cmd.current_dir(&self.cwd);
|
||||||
|
}
|
||||||
|
for (key, value) in &self.env {
|
||||||
|
cmd.env(key, value);
|
||||||
|
}
|
||||||
|
// OpenCode supporte déjà son propre confinement logique; le plan Landlock
|
||||||
|
// structuré reste réservé aux chemins process génériques existants.
|
||||||
|
let _ = (&self.sandbox, &self.sandbox_enforcer);
|
||||||
|
|
||||||
|
let mut child = cmd
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| AgentSessionError::Start(format!("{}: {e}", self.command)))?;
|
||||||
|
let stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| AgentSessionError::Io("stdout pipe indisponible".to_owned()))?;
|
||||||
|
let mut stderr_pipe = child
|
||||||
|
.stderr
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| AgentSessionError::Io("stderr pipe indisponible".to_owned()))?;
|
||||||
|
|
||||||
|
let mut lines = BufReader::new(stdout).lines();
|
||||||
|
let mut collected = Vec::new();
|
||||||
|
while let Some(line) = lines
|
||||||
|
.next_line()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentSessionError::Io(e.to_string()))?
|
||||||
|
{
|
||||||
|
collected.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stderr_bytes = Vec::new();
|
||||||
|
stderr_pipe
|
||||||
|
.read_to_end(&mut stderr_bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentSessionError::Io(e.to_string()))?;
|
||||||
|
let status = child
|
||||||
|
.wait()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentSessionError::Io(e.to_string()))?;
|
||||||
|
let stderr = String::from_utf8_lossy(&stderr_bytes);
|
||||||
|
if stderr.contains("server unavailable") && stderr.contains("key=idea") {
|
||||||
|
return Err(AgentSessionError::Start(
|
||||||
|
"serveur MCP OpenCode `idea` indisponible".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !status.success() {
|
||||||
|
return Err(AgentSessionError::Io(format!(
|
||||||
|
"OpenCode a quitté avec le statut {}: {}",
|
||||||
|
status,
|
||||||
|
stderr.trim()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let events = parse_jsonl_turn(&collected)?;
|
||||||
|
Ok(Box::new(events.into_iter()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_command_prefix_handles_quotes_without_shell() {
|
||||||
|
assert_eq!(
|
||||||
|
split_command_prefix(r#""/tmp/my opencode" --flag "two words" 'three words'"#).unwrap(),
|
||||||
|
vec!["/tmp/my opencode", "--flag", "two words", "three words"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_jsonl_turn_concatenates_text_and_adds_final() {
|
||||||
|
let events = parse_jsonl_turn(&[
|
||||||
|
r#"{"type":"step_start"}"#.to_owned(),
|
||||||
|
r#"{"type":"text","text":"hel"}"#.to_owned(),
|
||||||
|
r#"{"type":"text","text":"lo"}"#.to_owned(),
|
||||||
|
r#"{"type":"step_finish"}"#.to_owned(),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![
|
||||||
|
ReplyEvent::Heartbeat,
|
||||||
|
ReplyEvent::TextDelta {
|
||||||
|
text: "hel".to_owned()
|
||||||
|
},
|
||||||
|
ReplyEvent::TextDelta {
|
||||||
|
text: "lo".to_owned()
|
||||||
|
},
|
||||||
|
ReplyEvent::Heartbeat,
|
||||||
|
ReplyEvent::Final {
|
||||||
|
content: "hello".to_owned()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_jsonl_turn_rejects_empty_final() {
|
||||||
|
let err = parse_jsonl_turn(&[r#"{"type":"step_start"}"#.to_owned()]).unwrap_err();
|
||||||
|
assert!(matches!(err, AgentSessionError::Decode(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_jsonl_event_rejects_invalid_json() {
|
||||||
|
let err = parse_jsonl_event("{nope").unwrap_err();
|
||||||
|
assert!(matches!(err, AgentSessionError::Decode(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -470,7 +470,7 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
|
|||||||
let plan = rw_plan(&run_dir); // plan write-only ⇒ reads/exec du fake non gênés
|
let plan = rw_plan(&run_dir); // plan write-only ⇒ reads/exec du fake non gênés
|
||||||
|
|
||||||
let session = factory
|
let session = factory
|
||||||
.start(&profile, &ctx, &cwd, &SessionPlan::None, Some(&plan))
|
.start(&profile, &ctx, &cwd, &SessionPlan::None, &[], Some(&plan))
|
||||||
.await
|
.await
|
||||||
.expect("start sandboxé ok");
|
.expect("start sandboxé ok");
|
||||||
|
|
||||||
|
|||||||
@ -474,6 +474,7 @@ impl AgentSessionFactory for BlockingReplyFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
_session: &SessionPlan,
|
_session: &SessionPlan,
|
||||||
|
_env: &[(String, String)],
|
||||||
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||||
Ok(Arc::new(BlockingReplySession {
|
Ok(Arc::new(BlockingReplySession {
|
||||||
|
|||||||
@ -1160,20 +1160,18 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
|
|||||||
cwdTemplate: "{projectRoot}",
|
cwdTemplate: "{projectRoot}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mock-ollama",
|
id: "mock-opencode",
|
||||||
name: "Ollama / OpenAI-compatible local model",
|
name: "OpenCode + llama.cpp",
|
||||||
command: "openai-compatible",
|
command: "opencode",
|
||||||
args: [],
|
args: [],
|
||||||
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
||||||
detect: null,
|
detect: "opencode --version",
|
||||||
cwdTemplate: "{projectRoot}",
|
cwdTemplate: "{projectRoot}",
|
||||||
structuredAdapter: "openAiCompatible",
|
structuredAdapter: "openCode",
|
||||||
chatHttp: {
|
opencode: {
|
||||||
endpoint: "http://localhost:11434/v1",
|
baseURL: "http://localhost:8080/v1",
|
||||||
model: "qwen2.5-coder",
|
apiKey: "sk-no-key",
|
||||||
requestTimeoutMs: 120_000,
|
model: "qwen3-coder-30b",
|
||||||
connectTimeoutMs: 5_000,
|
|
||||||
maxToolIterations: 16,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -23,15 +23,15 @@ function customProfile(id: string, command: string): AgentProfile {
|
|||||||
describe("MockProfileGateway", () => {
|
describe("MockProfileGateway", () => {
|
||||||
it("firstRunState is first-run with only the selectable reference profiles", async () => {
|
it("firstRunState is first-run with only the selectable reference profiles", async () => {
|
||||||
// §17.3/D7: only structured-drivable profiles are offered (Claude/Codex CLIs
|
// §17.3/D7: only structured-drivable profiles are offered (Claude/Codex CLIs
|
||||||
// + the OpenAI-compatible local/LAN adapter, ticket #14); Gemini/Aider are
|
// + the OpenCode + llama.cpp local adapter); Gemini/Aider are filtered out of
|
||||||
// filtered out of the selection path server-side.
|
// the selection path server-side.
|
||||||
const gw = new MockProfileGateway();
|
const gw = new MockProfileGateway();
|
||||||
const state = await gw.firstRunState();
|
const state = await gw.firstRunState();
|
||||||
expect(state.isFirstRun).toBe(true);
|
expect(state.isFirstRun).toBe(true);
|
||||||
expect(state.referenceProfiles.map((p) => p.command)).toEqual([
|
expect(state.referenceProfiles.map((p) => p.command)).toEqual([
|
||||||
"claude",
|
"claude",
|
||||||
"codex",
|
"codex",
|
||||||
"openai-compatible",
|
"opencode",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -53,7 +53,7 @@ describe("MockProfileGateway", () => {
|
|||||||
expect(byCommand).toEqual({
|
expect(byCommand).toEqual({
|
||||||
claude: true,
|
claude: true,
|
||||||
codex: false,
|
codex: false,
|
||||||
"openai-compatible": false,
|
opencode: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -631,12 +631,12 @@ export type InjectionStrategy = ContextInjection["strategy"];
|
|||||||
* `StructuredAdapter`, camelCase wire format). Absent (`structuredAdapter`
|
* `StructuredAdapter`, camelCase wire format). Absent (`structuredAdapter`
|
||||||
* omitted) ⇒ the profile is a plain TUI/PTY agent (historical behaviour);
|
* omitted) ⇒ the profile is a plain TUI/PTY agent (historical behaviour);
|
||||||
* present ⇒ the profile is selectable as a structured AI agent:
|
* present ⇒ the profile is selectable as a structured AI agent:
|
||||||
* - `claude` / `codex`: driven by a spawned CLI binary,
|
* - `claude` / `codex` / `openCode`: driven by a spawned CLI binary,
|
||||||
* - `openAiCompatible`: driven by IdeA's native HTTP adapter against an
|
* - `openAiCompatible`: driven by IdeA's native HTTP adapter against an
|
||||||
* OpenAI-compatible chat server (Ollama, llama.cpp, a LAN runtime) — see
|
* OpenAI-compatible chat server (Ollama, llama.cpp, a LAN runtime) — see
|
||||||
* {@link HttpChatConfig}.
|
* {@link HttpChatConfig}.
|
||||||
*/
|
*/
|
||||||
export type StructuredAdapter = "claude" | "codex" | "openAiCompatible";
|
export type StructuredAdapter = "claude" | "codex" | "openCode" | "openAiCompatible";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP configuration of an OpenAI-compatible chat server (mirror of the backend
|
* HTTP configuration of an OpenAI-compatible chat server (mirror of the backend
|
||||||
@ -662,6 +662,33 @@ export interface HttpChatConfig {
|
|||||||
maxToolIterations?: number;
|
maxToolIterations?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for an OpenCode process-backed profile pointed at its own
|
||||||
|
* llama.cpp (OpenAI-compatible) endpoint. Mirror of the backend `OpenCodeConfig`
|
||||||
|
* (camelCase wire format: `baseURL`, `apiKey?`, `model`, `reasoning?`,
|
||||||
|
* `attachment?`). OpenCode is a locally-piloted CLI, distinct from the in-process
|
||||||
|
* {@link HttpChatConfig} adapter.
|
||||||
|
*/
|
||||||
|
export interface OpenCodeConfig {
|
||||||
|
/**
|
||||||
|
* OpenAI-compatible base URL served by `llama-server` (http/https), for
|
||||||
|
* example `http://localhost:8080/v1`.
|
||||||
|
*/
|
||||||
|
baseURL: string;
|
||||||
|
/**
|
||||||
|
* Optional API key forwarded to the provider — the key *itself*, not an env
|
||||||
|
* var name (a local llama.cpp typically needs none / a placeholder). Omitted
|
||||||
|
* when blank (mirrors the backend `skip_serializing_if`).
|
||||||
|
*/
|
||||||
|
apiKey?: string;
|
||||||
|
/** Model name served by `llama-server`, for example `qwen3-coder-30b`. */
|
||||||
|
model: string;
|
||||||
|
/** Enables model reasoning. Effective default: `true`. */
|
||||||
|
reasoning?: boolean;
|
||||||
|
/** Enables attachments. Effective default: `false`. */
|
||||||
|
attachment?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
|
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
|
||||||
* UUID string; `detect` is the optional detection command line.
|
* UUID string; `detect` is the optional detection command line.
|
||||||
@ -690,6 +717,8 @@ export interface AgentProfile {
|
|||||||
* every historical profile and every non-HTTP adapter.
|
* every historical profile and every non-HTTP adapter.
|
||||||
*/
|
*/
|
||||||
chatHttp?: HttpChatConfig;
|
chatHttp?: HttpChatConfig;
|
||||||
|
/** OpenCode process-backed config. Present for `structuredAdapter: "openCode"`. */
|
||||||
|
opencode?: OpenCodeConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Availability of a candidate profile after detection (mirror of the DTO). */
|
/** Availability of a candidate profile after detection (mirror of the DTO). */
|
||||||
|
|||||||
@ -176,130 +176,103 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("FirstRunWizard — OpenAI-compatible local/LAN profile (ticket #14)", () => {
|
describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
|
||||||
const OLLAMA = "Ollama / OpenAI-compatible local model";
|
const OPENCODE = "OpenCode + llama.cpp";
|
||||||
|
|
||||||
it("offers the local model as a selectable, pre-filled HTTP config row", async () => {
|
it("offers the local model as a selectable, pre-filled OpenCode config row", async () => {
|
||||||
renderWizard();
|
renderWizard();
|
||||||
await waitForLoaded();
|
await waitForLoaded();
|
||||||
|
|
||||||
expect(screen.getByText(OLLAMA)).toBeTruthy();
|
expect(screen.getByText(OPENCODE)).toBeTruthy();
|
||||||
// The structured HTTP fields are rendered, pre-filled from the reference.
|
// The OpenCode fields are rendered, pre-filled from the reference.
|
||||||
expect(
|
expect(
|
||||||
(screen.getByLabelText(`${OLLAMA} endpoint`) as HTMLInputElement).value,
|
(screen.getByLabelText(`${OPENCODE} base url`) as HTMLInputElement).value,
|
||||||
).toBe("http://localhost:11434/v1");
|
).toBe("http://localhost:8080/v1");
|
||||||
expect(
|
expect(
|
||||||
(screen.getByLabelText(`${OLLAMA} model`) as HTMLInputElement).value,
|
(screen.getByLabelText(`${OPENCODE} model`) as HTMLInputElement).value,
|
||||||
).toBe("qwen2.5-coder");
|
).toBe("qwen3-coder-30b");
|
||||||
// Claude/Codex rows must NOT grow HTTP fields (additive, no regression).
|
// Claude/Codex rows must NOT grow endpoint fields (additive, no regression).
|
||||||
expect(screen.queryByLabelText("Claude Code endpoint")).toBeNull();
|
expect(screen.queryByLabelText("Claude Code base url")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("labels the API key field as an env var NAME and flags a raw key", async () => {
|
it("exposes the API key as a free-form optional field (not an env var name)", async () => {
|
||||||
renderWizard();
|
renderWizard();
|
||||||
await waitForLoaded();
|
await waitForLoaded();
|
||||||
|
|
||||||
const apiKey = screen.getByLabelText(`${OLLAMA} api key env`) as HTMLInputElement;
|
const apiKey = screen.getByLabelText(`${OPENCODE} api key`) as HTMLInputElement;
|
||||||
// The label/placeholder make the env-var-name intent explicit.
|
expect(apiKey.value).toBe("sk-no-key");
|
||||||
expect(apiKey.placeholder.toLowerCase()).toContain("variable name");
|
|
||||||
|
|
||||||
// A raw secret is not a valid env var identifier ⇒ inline error.
|
// A raw secret is accepted verbatim (OpenCode carries the key itself) — no
|
||||||
|
// env-var-name error appears.
|
||||||
fireEvent.change(apiKey, { target: { value: "sk-secret-123" } });
|
fireEvent.change(apiKey, { target: { value: "sk-secret-123" } });
|
||||||
|
expect(apiKey.value).toBe("sk-secret-123");
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/valid env var name \(not the key itself\)/i),
|
screen.queryByText(/valid env var name/i),
|
||||||
).toBeTruthy();
|
|
||||||
|
|
||||||
// A proper env var name clears the error.
|
|
||||||
fireEvent.change(apiKey, { target: { value: "OPENAI_API_KEY" } });
|
|
||||||
expect(
|
|
||||||
screen.queryByText(/valid env var name \(not the key itself\)/i),
|
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flags an invalid endpoint scheme inline", async () => {
|
it("flags an invalid base URL scheme inline", async () => {
|
||||||
renderWizard();
|
renderWizard();
|
||||||
await waitForLoaded();
|
await waitForLoaded();
|
||||||
|
|
||||||
const endpoint = screen.getByLabelText(`${OLLAMA} endpoint`);
|
const baseUrl = screen.getByLabelText(`${OPENCODE} base url`);
|
||||||
fireEvent.change(endpoint, { target: { value: "ftp://oops" } });
|
fireEvent.change(baseUrl, { target: { value: "ftp://oops" } });
|
||||||
expect(screen.getByText(/must start with http:\/\/ or https:\/\//i)).toBeTruthy();
|
expect(screen.getByText(/must start with http:\/\/ or https:\/\//i)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exposes the timeout / tool-guard fields pre-filled from the reference", async () => {
|
it("flags an empty model inline", async () => {
|
||||||
renderWizard();
|
renderWizard();
|
||||||
await waitForLoaded();
|
await waitForLoaded();
|
||||||
|
|
||||||
expect(
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||||
(screen.getByLabelText(`${OLLAMA} request timeout`) as HTMLInputElement).value,
|
target: { value: "" },
|
||||||
).toBe("120000");
|
});
|
||||||
expect(
|
expect(screen.getByText(/model is required/i)).toBeTruthy();
|
||||||
(screen.getByLabelText(`${OLLAMA} connect timeout`) as HTMLInputElement).value,
|
|
||||||
).toBe("5000");
|
|
||||||
expect(
|
|
||||||
(screen.getByLabelText(`${OLLAMA} max tool iterations`) as HTMLInputElement)
|
|
||||||
.value,
|
|
||||||
).toBe("16");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists edited timeouts / tool-guard round-trip into chatHttp", async () => {
|
it("persists the edited OpenCode config round-trip when selected and saved", async () => {
|
||||||
const { profile } = renderWizard();
|
const { profile } = renderWizard();
|
||||||
await waitForLoaded();
|
await waitForLoaded();
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`));
|
// Select the local model and edit its base URL, model and API key.
|
||||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), {
|
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
||||||
target: { value: "90000" },
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} base url`), {
|
||||||
|
target: { value: "http://localhost:9090/v1" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} connect timeout`), {
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||||
target: { value: "3000" },
|
target: { value: "qwen3-coder-14b" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} max tool iterations`), {
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
||||||
target: { value: "8" },
|
target: { value: "sk-local" },
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||||
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
const saved = await profile.listProfiles();
|
const saved = await profile.listProfiles();
|
||||||
const ollama = saved.find((p) => p.command === "openai-compatible");
|
const opencode = saved.find((p) => p.command === "opencode");
|
||||||
expect(ollama?.chatHttp?.requestTimeoutMs).toBe(90000);
|
expect(opencode?.structuredAdapter).toBe("openCode");
|
||||||
expect(ollama?.chatHttp?.connectTimeoutMs).toBe(3000);
|
expect(opencode?.opencode?.baseURL).toBe("http://localhost:9090/v1");
|
||||||
expect(ollama?.chatHttp?.maxToolIterations).toBe(8);
|
expect(opencode?.opencode?.model).toBe("qwen3-coder-14b");
|
||||||
|
expect(opencode?.opencode?.apiKey).toBe("sk-local");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flags a zero timeout inline (mirror of the backend guard)", async () => {
|
it("clearing the API key drops it (optional, omitted when blank)", async () => {
|
||||||
renderWizard();
|
|
||||||
await waitForLoaded();
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), {
|
|
||||||
target: { value: "0" },
|
|
||||||
});
|
|
||||||
expect(screen.getByText(/positive integer \(ms\)/i)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists the edited HTTP config round-trip when selected and saved", async () => {
|
|
||||||
const { profile } = renderWizard();
|
const { profile } = renderWizard();
|
||||||
await waitForLoaded();
|
await waitForLoaded();
|
||||||
|
|
||||||
// Select the local model and edit its model name.
|
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
|
||||||
fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`));
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
||||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} model`), {
|
target: { value: "" },
|
||||||
target: { value: "qwen3" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} api key env`), {
|
|
||||||
target: { value: "LAN_KEY" },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||||
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
const saved = await profile.listProfiles();
|
const saved = await profile.listProfiles();
|
||||||
const ollama = saved.find((p) => p.command === "openai-compatible");
|
const opencode = saved.find((p) => p.command === "opencode");
|
||||||
expect(ollama?.structuredAdapter).toBe("openAiCompatible");
|
expect(opencode?.opencode?.apiKey).toBeUndefined();
|
||||||
expect(ollama?.chatHttp?.model).toBe("qwen3");
|
|
||||||
expect(ollama?.chatHttp?.apiKeyEnv).toBe("LAN_KEY");
|
|
||||||
// The endpoint round-trips untouched.
|
|
||||||
expect(ollama?.chatHttp?.endpoint).toBe("http://localhost:11434/v1");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,11 +15,12 @@
|
|||||||
* `./profile`.
|
* `./profile`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AgentProfile, HttpChatConfig } from "@/domain";
|
import type { AgentProfile, HttpChatConfig, OpenCodeConfig } from "@/domain";
|
||||||
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
||||||
import { useFirstRun, type WizardEntry } from "./useFirstRun";
|
import { useFirstRun, type WizardEntry } from "./useFirstRun";
|
||||||
import {
|
import {
|
||||||
defaultHttpChatConfig,
|
defaultHttpChatConfig,
|
||||||
|
defaultOpenCodeConfig,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
validateProfile,
|
validateProfile,
|
||||||
type ProfileErrors,
|
type ProfileErrors,
|
||||||
@ -190,10 +191,81 @@ function ProfileRow({
|
|||||||
{profile.structuredAdapter === "openAiCompatible" && (
|
{profile.structuredAdapter === "openAiCompatible" && (
|
||||||
<HttpChatFields profile={profile} errors={errors} onChange={onChange} />
|
<HttpChatFields profile={profile} errors={errors} onChange={onChange} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{profile.structuredAdapter === "openCode" && (
|
||||||
|
<OpenCodeFields profile={profile} errors={errors} onChange={onChange} />
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OpenCode + llama.cpp config section: the base URL of the local
|
||||||
|
* `llama-server` (`/v1`), the model it serves, and an optional API key (the key
|
||||||
|
* itself — a local llama.cpp usually needs none). Rendered only for a `openCode`
|
||||||
|
* profile; edits flow back through `onChange` as a patched `opencode`.
|
||||||
|
*/
|
||||||
|
function OpenCodeFields({
|
||||||
|
profile,
|
||||||
|
errors,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
profile: AgentProfile;
|
||||||
|
errors: ProfileErrors;
|
||||||
|
onChange: (p: AgentProfile) => void;
|
||||||
|
}) {
|
||||||
|
const opencode = profile.opencode ?? defaultOpenCodeConfig();
|
||||||
|
const patch = (next: Partial<OpenCodeConfig>) =>
|
||||||
|
onChange({ ...profile, opencode: { ...opencode, ...next } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
|
||||||
|
<legend className="px-1 text-xs font-medium text-muted">
|
||||||
|
Local model (OpenCode + llama.cpp)
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Base URL</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} base url`}
|
||||||
|
value={opencode.baseURL}
|
||||||
|
placeholder="http://localhost:8080/v1"
|
||||||
|
invalid={Boolean(errors.baseURL)}
|
||||||
|
onChange={(e) => patch({ baseURL: e.target.value })}
|
||||||
|
/>
|
||||||
|
{errors.baseURL && (
|
||||||
|
<small className="text-xs text-danger">{errors.baseURL}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Model</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} model`}
|
||||||
|
value={opencode.model}
|
||||||
|
placeholder="qwen3-coder-30b"
|
||||||
|
invalid={Boolean(errors.model)}
|
||||||
|
onChange={(e) => patch({ model: e.target.value })}
|
||||||
|
/>
|
||||||
|
{errors.model && <small className="text-xs text-danger">{errors.model}</small>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>API key (optional — a local llama.cpp needs none)</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} api key`}
|
||||||
|
value={opencode.apiKey ?? ""}
|
||||||
|
placeholder="e.g. sk-no-key"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
patch({ apiKey: v.length === 0 ? undefined : v });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Parses a number field: blank ⇒ `undefined` (backend applies its default). */
|
/** Parses a number field: blank ⇒ `undefined` (backend applies its default). */
|
||||||
function parseOptInt(raw: string): number | undefined {
|
function parseOptInt(raw: string): number | undefined {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import type { AgentProfile } from "@/domain";
|
|||||||
import {
|
import {
|
||||||
defaultHttpChatConfig,
|
defaultHttpChatConfig,
|
||||||
defaultInjection,
|
defaultInjection,
|
||||||
|
defaultOpenCodeConfig,
|
||||||
emptyCustomProfile,
|
emptyCustomProfile,
|
||||||
isProfileValid,
|
isProfileValid,
|
||||||
isRelativeSafe,
|
isRelativeSafe,
|
||||||
@ -16,6 +17,7 @@ import {
|
|||||||
isValidHttpUrl,
|
isValidHttpUrl,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
validateHttpChatConfig,
|
validateHttpChatConfig,
|
||||||
|
validateOpenCodeConfig,
|
||||||
validateProfile,
|
validateProfile,
|
||||||
} from "./profile";
|
} from "./profile";
|
||||||
|
|
||||||
@ -189,6 +191,58 @@ describe("validateProfile for openAiCompatible profiles", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("validateOpenCodeConfig", () => {
|
||||||
|
it("a well-formed llama.cpp config is valid", () => {
|
||||||
|
expect(validateOpenCodeConfig(defaultOpenCodeConfig())).toEqual({});
|
||||||
|
});
|
||||||
|
it("reports a bad base URL scheme and an empty model", () => {
|
||||||
|
const errors = validateOpenCodeConfig({ baseURL: "ftp://x", model: " " });
|
||||||
|
expect(errors.baseURL).toBeDefined();
|
||||||
|
expect(errors.model).toBeDefined();
|
||||||
|
});
|
||||||
|
it("an optional API key is free-form (no env-var-name constraint)", () => {
|
||||||
|
// Unlike the openAiCompatible adapter, OpenCode carries the key itself.
|
||||||
|
expect(
|
||||||
|
validateOpenCodeConfig({
|
||||||
|
baseURL: "http://localhost:8080/v1",
|
||||||
|
apiKey: "sk-secret-123",
|
||||||
|
model: "qwen3-coder-30b",
|
||||||
|
}),
|
||||||
|
).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateProfile for openCode profiles", () => {
|
||||||
|
function opencode(overrides: Partial<AgentProfile> = {}): AgentProfile {
|
||||||
|
return base({
|
||||||
|
name: "OpenCode + llama.cpp",
|
||||||
|
command: "opencode",
|
||||||
|
structuredAdapter: "openCode",
|
||||||
|
opencode: defaultOpenCodeConfig(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
it("the reference llama.cpp profile is valid", () => {
|
||||||
|
expect(validateProfile(opencode())).toEqual({});
|
||||||
|
expect(isProfileValid(opencode())).toBe(true);
|
||||||
|
});
|
||||||
|
it("surfaces opencode field errors through the profile validator", () => {
|
||||||
|
const errors = validateProfile(
|
||||||
|
opencode({ opencode: { baseURL: "nope", model: "" } }),
|
||||||
|
);
|
||||||
|
expect(errors.baseURL).toBeDefined();
|
||||||
|
expect(errors.model).toBeDefined();
|
||||||
|
expect(
|
||||||
|
isProfileValid(opencode({ opencode: { baseURL: "nope", model: "m" } })),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
it("a missing opencode config on an openCode profile is invalid", () => {
|
||||||
|
const errors = validateProfile(opencode({ opencode: undefined }));
|
||||||
|
expect(errors.baseURL).toBeDefined();
|
||||||
|
expect(errors.model).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("defaultInjection", () => {
|
describe("defaultInjection", () => {
|
||||||
it("produces a sensible default per strategy", () => {
|
it("produces a sensible default per strategy", () => {
|
||||||
expect(defaultInjection("conventionFile")).toEqual({
|
expect(defaultInjection("conventionFile")).toEqual({
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import type {
|
|||||||
ContextInjection,
|
ContextInjection,
|
||||||
HttpChatConfig,
|
HttpChatConfig,
|
||||||
InjectionStrategy,
|
InjectionStrategy,
|
||||||
|
OpenCodeConfig,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
|
|
||||||
/** A field-keyed validation error map (empty ⇒ valid). */
|
/** A field-keyed validation error map (empty ⇒ valid). */
|
||||||
@ -20,6 +21,7 @@ export type ProfileErrors = Partial<
|
|||||||
| "flag"
|
| "flag"
|
||||||
| "var"
|
| "var"
|
||||||
| "endpoint"
|
| "endpoint"
|
||||||
|
| "baseURL"
|
||||||
| "model"
|
| "model"
|
||||||
| "apiKeyEnv"
|
| "apiKeyEnv"
|
||||||
| "requestTimeoutMs"
|
| "requestTimeoutMs"
|
||||||
@ -95,6 +97,23 @@ export function validateHttpChatConfig(c: HttpChatConfig): ProfileErrors {
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an {@link OpenCodeConfig} draft the way the backend
|
||||||
|
* `OpenCodeConfig::new` would (base URL http/https + non-empty, model non-empty;
|
||||||
|
* the API key is a free-form, optional secret). Returns an empty object when the
|
||||||
|
* draft is valid.
|
||||||
|
*/
|
||||||
|
export function validateOpenCodeConfig(c: OpenCodeConfig): ProfileErrors {
|
||||||
|
const errors: ProfileErrors = {};
|
||||||
|
if (!isValidHttpUrl(c.baseURL)) {
|
||||||
|
errors.baseURL = "Base URL must start with http:// or https://.";
|
||||||
|
}
|
||||||
|
if (c.model.trim().length === 0) {
|
||||||
|
errors.model = "Model is required.";
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates a profile draft the way the backend would, so the wizard can surface
|
* Validates a profile draft the way the backend would, so the wizard can surface
|
||||||
* errors before any `invoke`. Returns an empty object when the draft is valid.
|
* errors before any `invoke`. Returns an empty object when the draft is valid.
|
||||||
@ -125,6 +144,16 @@ export function validateProfile(p: AgentProfile): ProfileErrors {
|
|||||||
Object.assign(errors, validateHttpChatConfig(p.chatHttp));
|
Object.assign(errors, validateHttpChatConfig(p.chatHttp));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An OpenCode profile carries its own llama.cpp endpoint config; the backend
|
||||||
|
// refuses to persist it unless base URL + model are well-formed, so mirror that.
|
||||||
|
if (p.structuredAdapter === "openCode") {
|
||||||
|
if (!p.opencode) {
|
||||||
|
errors.baseURL = "Base URL must start with http:// or https://.";
|
||||||
|
errors.model = "Model is required.";
|
||||||
|
} else {
|
||||||
|
Object.assign(errors, validateOpenCodeConfig(p.opencode));
|
||||||
|
}
|
||||||
|
}
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,6 +194,19 @@ export function defaultHttpChatConfig(): HttpChatConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sensible default {@link OpenCodeConfig} for a fresh OpenCode profile draft —
|
||||||
|
* pre-fills a local `llama-server` endpoint so the form starts valid. Mirrors the
|
||||||
|
* backend reference profile (`http://localhost:8080/v1`, `qwen3-coder-30b`).
|
||||||
|
*/
|
||||||
|
export function defaultOpenCodeConfig(): OpenCodeConfig {
|
||||||
|
return {
|
||||||
|
baseURL: "http://localhost:8080/v1",
|
||||||
|
apiKey: "sk-no-key",
|
||||||
|
model: "qwen3-coder-30b",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** A fresh, empty custom-profile draft (id minted client-side). */
|
/** A fresh, empty custom-profile draft (id minted client-side). */
|
||||||
export function emptyCustomProfile(): AgentProfile {
|
export function emptyCustomProfile(): AgentProfile {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user