feat(model-server): backend modèles locaux — serveur llama.cpp intégré (#35) et profils OpenCode locaux multiples (#36)
Sprint « Modeles locaux », couche backend (domain/application/infra/app-tauri). #35 — Serveur de modèle local intégré (llama.cpp) : - domain: model_server.rs (agrégat + statut), ports ModelServerProbe / ManagedProcess / ModelServerRuntime / ModelServerRegistry, événements model_server_status_changed et agent_launch_failed. - application: use case EnsureLocalModelServer branché sur LaunchAgent. - infrastructure: adapters HttpOpenAiCompatibleProbe, LlamaCppRuntime, LocalManagedProcess, FsModelServerRegistry. - app-tauri: DTO plat LocalModelServerConfigDto, commandes list/save/delete_model_server avec garde model_server_in_use, wiring. #36 — Profils OpenCode locaux multiples : - domain: VO LocalModelServerId, OpenCodeConfig.local_model_server_id. - application: use case CloneOpenCodeProfileFromSeed. - app-tauri: commande clone_opencode_profile_from_seed, DTO/wiring. Tests verts (exécution réelle) : domain+application OK, app-tauri dto_model_servers 3/3 et dto_profiles 10/10, infra model_server 2/2, application model_server+profile_usecases 19/19, cargo build workspace Finished. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
512
crates/application/tests/model_server.rs
Normal file
512
crates/application/tests/model_server.rs
Normal file
@ -0,0 +1,512 @@
|
||||
//! Unit tests for the local model-server ensure use case.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::{
|
||||
DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput,
|
||||
ModelServerReadinessPolicy,
|
||||
};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::model_server::{
|
||||
ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath,
|
||||
ModelServerEndpoint, ModelServerStatus, StopPolicy,
|
||||
};
|
||||
use domain::ports::{
|
||||
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
|
||||
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
|
||||
ProfileStore, RemotePath, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
||||
use domain::{LocalModelServerId, ProfileId, ProjectPath};
|
||||
|
||||
fn sid(n: u128) -> LocalModelServerId {
|
||||
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn config(
|
||||
id: LocalModelServerId,
|
||||
port: u16,
|
||||
path: &str,
|
||||
auto_start: bool,
|
||||
) -> LocalModelServerConfig {
|
||||
LocalModelServerConfig::new(
|
||||
id,
|
||||
LocalModelServerKind::LlamaCpp,
|
||||
"llama.cpp",
|
||||
ModelServerEndpoint::new(format!("http://localhost:{port}"), port).unwrap(),
|
||||
LocalModelRef::new(
|
||||
"qwen",
|
||||
"Qwen",
|
||||
Some(ModelPath::new(path).unwrap()),
|
||||
"qwen3-coder-30b",
|
||||
)
|
||||
.unwrap(),
|
||||
Some(ExecutablePath::new("llama-server").unwrap()),
|
||||
Vec::new(),
|
||||
auto_start,
|
||||
StopPolicy::StopOnAppExit,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeRegistry(Mutex<HashMap<LocalModelServerId, LocalModelServerConfig>>);
|
||||
|
||||
#[async_trait]
|
||||
impl ModelServerRegistry for FakeRegistry {
|
||||
async fn get(
|
||||
&self,
|
||||
id: &LocalModelServerId,
|
||||
) -> Result<Option<LocalModelServerConfig>, ModelServerError> {
|
||||
Ok(self.0.lock().unwrap().get(id).cloned())
|
||||
}
|
||||
|
||||
async fn list(&self) -> Result<Vec<LocalModelServerConfig>, ModelServerError> {
|
||||
Ok(self.0.lock().unwrap().values().cloned().collect())
|
||||
}
|
||||
|
||||
async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError> {
|
||||
self.0.lock().unwrap().insert(config.id, config);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError> {
|
||||
self.0.lock().unwrap().remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeProfiles(Mutex<Vec<AgentProfile>>);
|
||||
|
||||
#[async_trait]
|
||||
impl ProfileStore for FakeProfiles {
|
||||
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
|
||||
self.0.lock().unwrap().push(profile.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_configured(&self) -> Result<bool, StoreError> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn mark_configured(&self) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn opencode_profile(id: u128, server_id: LocalModelServerId) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
|
||||
"Local OpenCode",
|
||||
"opencode",
|
||||
Vec::new(),
|
||||
ContextInjection::stdin(),
|
||||
None,
|
||||
"{projectRoot}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_structured_adapter(StructuredAdapter::OpenCode)
|
||||
.with_opencode(
|
||||
OpenCodeConfig::new("http://localhost:8080/v1", None, "qwen", None, None)
|
||||
.unwrap()
|
||||
.with_local_model_server_id(server_id),
|
||||
)
|
||||
}
|
||||
|
||||
struct FakeProbe(Mutex<VecDeque<ModelServerStatus>>);
|
||||
|
||||
impl FakeProbe {
|
||||
fn new(statuses: Vec<ModelServerStatus>) -> Self {
|
||||
Self(Mutex::new(statuses.into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelServerProbe for FakeProbe {
|
||||
async fn probe(
|
||||
&self,
|
||||
_endpoint: &ModelServerEndpoint,
|
||||
) -> Result<ModelServerStatus, ModelServerError> {
|
||||
Ok(self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or(ModelServerStatus::Unreachable))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeProcess {
|
||||
spawns: Mutex<Vec<SpawnSpec>>,
|
||||
kills: Mutex<Vec<String>>,
|
||||
statuses: Mutex<HashMap<String, ProcessStatus>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ManagedProcess for FakeProcess {
|
||||
async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError> {
|
||||
self.spawns.lock().unwrap().push(spec);
|
||||
let handle = ManagedProcessHandle {
|
||||
id: format!("h{}", self.spawns.lock().unwrap().len()),
|
||||
};
|
||||
self.statuses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(handle.id.clone(), ProcessStatus::Running);
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError> {
|
||||
self.kills.lock().unwrap().push(handle.id.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn status(
|
||||
&self,
|
||||
handle: &ManagedProcessHandle,
|
||||
) -> Result<ProcessStatus, ModelServerError> {
|
||||
Ok(*self
|
||||
.statuses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&handle.id)
|
||||
.unwrap_or(&ProcessStatus::Unknown))
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeRuntime;
|
||||
|
||||
impl ModelServerRuntime for FakeRuntime {
|
||||
fn build_spawn_spec(
|
||||
&self,
|
||||
config: &LocalModelServerConfig,
|
||||
) -> Result<SpawnSpec, ModelServerError> {
|
||||
Ok(SpawnSpec {
|
||||
command: config
|
||||
.binary
|
||||
.as_ref()
|
||||
.map(|binary| binary.as_str().to_owned())
|
||||
.unwrap_or_else(|| "llama-server".to_owned()),
|
||||
args: vec![
|
||||
"--model".to_owned(),
|
||||
config.model.path.as_ref().unwrap().as_str().to_owned(),
|
||||
"--port".to_owned(),
|
||||
config.endpoint.port.to_string(),
|
||||
],
|
||||
cwd: ProjectPath::new("/").unwrap(),
|
||||
env: Vec::new(),
|
||||
context_plan: None,
|
||||
sandbox: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeFs {
|
||||
existing: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileSystem for FakeFs {
|
||||
async fn read(&self, _path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
Err(FsError::NotFound("unused".to_owned()))
|
||||
}
|
||||
|
||||
async fn write(&self, _path: &RemotePath, _data: &[u8]) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
||||
Ok(self
|
||||
.existing
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| p == path.as_str()))
|
||||
}
|
||||
|
||||
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeEvents(Mutex<Vec<DomainEvent>>);
|
||||
|
||||
impl EventBus for FakeEvents {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(
|
||||
registry: Arc<FakeRegistry>,
|
||||
probe: Arc<FakeProbe>,
|
||||
process: Arc<FakeProcess>,
|
||||
fs: Arc<FakeFs>,
|
||||
events: Arc<FakeEvents>,
|
||||
) -> EnsureLocalModelServer {
|
||||
EnsureLocalModelServer::new(registry, probe, process, Arc::new(FakeRuntime), fs, events)
|
||||
.with_readiness_policy(ModelServerReadinessPolicy {
|
||||
attempts: 2,
|
||||
backoff: Duration::ZERO,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reachable_server_is_reused_without_spawn() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(1), 8080, "/models/qwen.gguf", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let process = Arc::new(FakeProcess::default());
|
||||
let usecase = ensure(
|
||||
Arc::clone(®istry),
|
||||
Arc::new(FakeProbe::new(vec![ModelServerStatus::ReadyReused])),
|
||||
Arc::clone(&process),
|
||||
Arc::new(FakeFs::default()),
|
||||
Arc::new(FakeEvents::default()),
|
||||
);
|
||||
|
||||
let out = usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(1) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.ready.base_url, "http://localhost:8080/v1");
|
||||
assert_eq!(out.ready.model, "qwen3-coder-30b");
|
||||
assert!(process.spawns.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn absent_auto_start_spawns_and_waits_until_ready() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(2), 8081, "/models/qwen.gguf", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.existing
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("/models/qwen.gguf".to_owned());
|
||||
let process = Arc::new(FakeProcess::default());
|
||||
let usecase = ensure(
|
||||
Arc::clone(®istry),
|
||||
Arc::new(FakeProbe::new(vec![
|
||||
ModelServerStatus::Unreachable,
|
||||
ModelServerStatus::ReadyReused,
|
||||
])),
|
||||
Arc::clone(&process),
|
||||
fs,
|
||||
Arc::new(FakeEvents::default()),
|
||||
);
|
||||
|
||||
let out = usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(2) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.ready.status, ModelServerStatus::ReadyStarted);
|
||||
let spawns = process.spawns.lock().unwrap();
|
||||
assert_eq!(spawns.len(), 1);
|
||||
assert_eq!(spawns[0].command, "llama-server");
|
||||
assert_eq!(
|
||||
spawns[0].args,
|
||||
vec!["--model", "/models/qwen.gguf", "--port", "8081"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_model_path_is_path_not_accessible() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(3), 8082, "/models/missing.gguf", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let usecase = ensure(
|
||||
Arc::clone(®istry),
|
||||
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
|
||||
Arc::new(FakeProcess::default()),
|
||||
Arc::new(FakeFs::default()),
|
||||
Arc::new(FakeEvents::default()),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(3) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "MODEL_SERVER");
|
||||
assert!(err.to_string().contains("path_not_accessible"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_managed_port_collision_is_explicit_error() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(4), 8083, "/models/a.gguf", true))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.save(config(sid(5), 8083, "/models/b.gguf", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.existing
|
||||
.lock()
|
||||
.unwrap()
|
||||
.extend(["/models/a.gguf".to_owned(), "/models/b.gguf".to_owned()]);
|
||||
let process = Arc::new(FakeProcess::default());
|
||||
let events = Arc::new(FakeEvents::default());
|
||||
let usecase = ensure(
|
||||
Arc::clone(®istry),
|
||||
Arc::new(FakeProbe::new(vec![
|
||||
ModelServerStatus::Unreachable,
|
||||
ModelServerStatus::ReadyReused,
|
||||
ModelServerStatus::Unreachable,
|
||||
])),
|
||||
Arc::clone(&process),
|
||||
fs,
|
||||
events,
|
||||
);
|
||||
|
||||
usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(4) })
|
||||
.await
|
||||
.unwrap();
|
||||
let err = usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(5) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("port_occupied"));
|
||||
assert_eq!(process.spawns.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_timeout_kills_started_process() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(6), 8084, "/models/qwen.gguf", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.existing
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("/models/qwen.gguf".to_owned());
|
||||
let process = Arc::new(FakeProcess::default());
|
||||
let usecase = ensure(
|
||||
Arc::clone(®istry),
|
||||
Arc::new(FakeProbe::new(vec![
|
||||
ModelServerStatus::Unreachable,
|
||||
ModelServerStatus::Unreachable,
|
||||
ModelServerStatus::Unreachable,
|
||||
])),
|
||||
Arc::clone(&process),
|
||||
fs,
|
||||
Arc::new(FakeEvents::default()),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(6) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("timeout"));
|
||||
assert_eq!(process.spawns.lock().unwrap().len(), 1);
|
||||
assert_eq!(process.kills.lock().unwrap().as_slice(), ["h1"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_registry_entry_is_model_server_not_configured() {
|
||||
let usecase = ensure(
|
||||
Arc::new(FakeRegistry::default()),
|
||||
Arc::new(FakeProbe::new(Vec::new())),
|
||||
Arc::new(FakeProcess::default()),
|
||||
Arc::new(FakeFs::default()),
|
||||
Arc::new(FakeEvents::default()),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(7) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "MODEL_SERVER");
|
||||
assert!(err.to_string().contains("not_configured"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_server_refuses_when_profile_references_it() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(8), 8085, "/models/qwen.gguf", false))
|
||||
.await
|
||||
.unwrap();
|
||||
let profiles = Arc::new(FakeProfiles(Mutex::new(vec![opencode_profile(18, sid(8))])));
|
||||
let usecase = DeleteModelServer::new(registry, profiles);
|
||||
|
||||
let err = usecase
|
||||
.execute(DeleteModelServerInput { server_id: sid(8) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "MODEL_SERVER");
|
||||
match err {
|
||||
application::AppError::ModelServer { code, .. } => {
|
||||
assert_eq!(code, "model_server_in_use");
|
||||
}
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_server_removes_unused_config() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(9), 8086, "/models/qwen.gguf", false))
|
||||
.await
|
||||
.unwrap();
|
||||
let profiles = Arc::new(FakeProfiles::default());
|
||||
let usecase = DeleteModelServer::new(
|
||||
Arc::clone(®istry) as Arc<dyn ModelServerRegistry>,
|
||||
profiles,
|
||||
);
|
||||
|
||||
usecase
|
||||
.execute(DeleteModelServerInput { server_id: sid(9) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(registry.get(&sid(9)).await.unwrap().is_none());
|
||||
}
|
||||
@ -13,15 +13,17 @@ use async_trait::async_trait;
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{
|
||||
AgentRuntime, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec, StoreError,
|
||||
AgentRuntime, IdGenerator, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec,
|
||||
StoreError,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
use application::{
|
||||
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
|
||||
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState,
|
||||
ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
||||
reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed,
|
||||
CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles,
|
||||
ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -127,6 +129,20 @@ impl AgentRuntime for YieldingRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
struct SeqIds(Mutex<Vec<uuid::Uuid>>);
|
||||
|
||||
impl SeqIds {
|
||||
fn new(ids: Vec<uuid::Uuid>) -> Self {
|
||||
Self(Mutex::new(ids))
|
||||
}
|
||||
}
|
||||
|
||||
impl IdGenerator for SeqIds {
|
||||
fn new_uuid(&self) -> uuid::Uuid {
|
||||
self.0.lock().unwrap().remove(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
|
||||
@ -327,6 +343,103 @@ async fn delete_unknown_is_not_found_error() {
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clone_opencode_profile_from_seed_creates_distinct_open_code_instance() {
|
||||
let store = FakeProfileStore::default();
|
||||
let clone = CloneOpenCodeProfileFromSeed::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3601)])),
|
||||
);
|
||||
|
||||
let out = clone
|
||||
.execute(CloneOpenCodeProfileFromSeedInput {
|
||||
name: Some("OpenCode llama.cpp 2".to_owned()),
|
||||
opencode: Some(
|
||||
OpenCodeConfig::new(
|
||||
"http://localhost:8081/v1",
|
||||
None,
|
||||
"qwen3-coder-70b",
|
||||
Some(false),
|
||||
Some(true),
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let seed_id = reference_profile_id("opencode-llamacpp");
|
||||
assert_ne!(out.profile.id, seed_id);
|
||||
assert_eq!(
|
||||
out.profile.id,
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(3601))
|
||||
);
|
||||
assert_eq!(out.profile.name, "OpenCode llama.cpp 2");
|
||||
assert_eq!(
|
||||
out.profile.structured_adapter,
|
||||
Some(StructuredAdapter::OpenCode)
|
||||
);
|
||||
assert_eq!(
|
||||
out.profile
|
||||
.opencode
|
||||
.as_ref()
|
||||
.map(|config| config.base_url.as_str()),
|
||||
Some("http://localhost:8081/v1")
|
||||
);
|
||||
|
||||
let profiles = store.0.lock().unwrap().profiles.clone();
|
||||
assert_eq!(profiles.len(), 1);
|
||||
assert_eq!(profiles[0].id, out.profile.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clone_opencode_profile_prefers_persisted_seed_without_recreating_it() {
|
||||
let store = FakeProfileStore::default();
|
||||
let save = SaveProfile::new(Arc::new(store.clone()));
|
||||
let mut seed = reference_profiles()
|
||||
.into_iter()
|
||||
.find(|profile| profile.id == reference_profile_id("opencode-llamacpp"))
|
||||
.expect("seed exists");
|
||||
seed.name = "Edited local OpenCode seed".to_owned();
|
||||
save.execute(SaveProfileInput {
|
||||
profile: seed.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let clone = CloneOpenCodeProfileFromSeed::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3602)])),
|
||||
);
|
||||
let out = clone
|
||||
.execute(CloneOpenCodeProfileFromSeedInput {
|
||||
name: None,
|
||||
opencode: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.profile.name, "Edited local OpenCode seed copy");
|
||||
assert_eq!(out.profile.opencode, seed.opencode);
|
||||
let profiles = store.0.lock().unwrap().profiles.clone();
|
||||
assert_eq!(
|
||||
profiles
|
||||
.iter()
|
||||
.filter(|profile| profile.id == reference_profile_id("opencode-llamacpp"))
|
||||
.count(),
|
||||
1,
|
||||
"the canonical seed must be preserved, not recreated as a duplicate"
|
||||
);
|
||||
assert_eq!(
|
||||
profiles
|
||||
.iter()
|
||||
.filter(|profile| profile.structured_adapter == Some(StructuredAdapter::OpenCode))
|
||||
.count(),
|
||||
2,
|
||||
"ProfileId is the identity: multiple OpenCode profiles can coexist"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReferenceProfiles / catalogue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user