feat(opencode): remplace le provider Ollama par llama.cpp

Le tool-calling local ne fonctionnait jamais via Ollama. Refonte du
support local d'OpenCode autour de llama.cpp: profil, catalogue,
matérialisation de la config OpenCode et surface first-run alignés sur
llama-server (backend + frontend).

QA vert (commandes réelles): domain 244, application 81+64, infra 263
(10 échecs = bind-port sandbox identiques sur develop, non-régression),
frontend 574, tsc propre. Réserve E2E live non bloquante faute de
llama-server joignable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 00:41:19 +02:00
parent eaba05d27d
commit 4e70631c40
12 changed files with 478 additions and 140 deletions

View File

@ -1587,7 +1587,16 @@ fn profile_with_mcp(id: ProfileId, config: McpConfigStrategy) -> AgentProfile {
fn opencode_profile(id: ProfileId) -> AgentProfile {
profile(id, ContextInjection::convention_file("AGENTS.md").unwrap())
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode(OpenCodeConfig::new(Some("ollama/qwen3-coder:30b".to_owned())).unwrap())
.with_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,
@ -1730,10 +1739,34 @@ async fn launch_opencode_writes_isolated_config_and_env() {
assert_eq!(opencode.len(), 1, "one OpenCode config is written");
assert_eq!(opencode[0].0, format!("{run_dir}/opencode.json"));
let body: serde_json::Value = serde_json::from_slice(&opencode[0].1).unwrap();
assert_eq!(body["model"], "ollama/qwen3-coder:30b");
assert_eq!(body["model"], "llamacpp/qwen3-coder-30b");
assert_eq!(
body["provider"]["ollama"]["options"]["baseURL"],
"http://localhost:11434/v1"
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");
@ -2496,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]
async fn launch_with_store_without_handoff_omits_resume_section() {
// Provider wired, valid conversation_id, but the store holds NO handoff for it