From b82ac76f8b71ecf93b6b039cbb21820714b453b5 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 11 Jul 2026 15:50:08 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(model-server):=20backend=20mod=C3=A8le?= =?UTF-8?q?s=20locaux=20=E2=80=94=20serveur=20llama.cpp=20int=C3=A9gr?= =?UTF-8?q?=C3=A9=20(#35)=20et=20profils=20OpenCode=20locaux=20multiples?= =?UTF-8?q?=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/app-tauri/src/commands.rs | 124 ++++- crates/app-tauri/src/dto.rs | 282 +++++++++- crates/app-tauri/src/events.rs | 171 +++++- crates/app-tauri/src/lib.rs | 7 + crates/app-tauri/src/state.rs | 110 ++-- crates/app-tauri/tests/dto_model_servers.rs | 111 ++++ crates/app-tauri/tests/dto_profiles.rs | 54 +- crates/application/src/agent/lifecycle.rs | 72 ++- crates/application/src/agent/mod.rs | 10 +- crates/application/src/agent/usecases.rs | 112 +++- crates/application/src/error.rs | 38 +- crates/application/src/lib.rs | 34 +- crates/application/src/model_server.rs | 447 +++++++++++++++ crates/application/tests/model_server.rs | 512 ++++++++++++++++++ crates/application/tests/profile_usecases.rs | 123 ++++- crates/domain/src/events.rs | 21 +- crates/domain/src/ids.rs | 4 + crates/domain/src/lib.rs | 11 +- crates/domain/src/model_server.rs | 368 +++++++++++++ crates/domain/src/ports.rs | 143 ++++- crates/domain/src/profile.rs | 57 +- crates/infrastructure/src/lib.rs | 4 + crates/infrastructure/src/model_server/mod.rs | 315 +++++++++++ crates/infrastructure/tests/model_server.rs | 111 ++++ 24 files changed, 3147 insertions(+), 94 deletions(-) create mode 100644 crates/app-tauri/tests/dto_model_servers.rs create mode 100644 crates/application/src/model_server.rs create mode 100644 crates/application/tests/model_server.rs create mode 100644 crates/domain/src/model_server.rs create mode 100644 crates/infrastructure/src/model_server/mod.rs create mode 100644 crates/infrastructure/tests/model_server.rs diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 0954d44..f884996 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -34,28 +34,30 @@ use crate::dto::{ parse_task_id, parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, - ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, - CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, - CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, - DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto, - DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto, - EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, - FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, - GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto, - HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto, - LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, - MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto, + CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, + CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, + CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, + CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, + DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, + EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, + ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, + GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, + GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, + InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, + LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, + ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto, - SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, - SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto, - StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, - TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, - UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, - UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, - UpdateTemplateRequestDto, WriteTerminalRequestDto, + SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto, + SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, + StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, + SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, + TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, + UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, + UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, + WriteTerminalRequestDto, parse_model_server_id, save_model_server_input, }; use crate::pty::{PtyBridge, PtyChunk}; use crate::state::AppState; @@ -793,6 +795,25 @@ pub async fn save_profile( .map_err(ErrorDto::from) } +/// `clone_opencode_profile_from_seed` — create a new OpenCode profile instance +/// from the canonical `opencode-llamacpp` seed/template. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure, `INVALID` for a +/// blank requested name). +#[tauri::command] +pub async fn clone_opencode_profile_from_seed( + request: CloneOpenCodeProfileFromSeedRequestDto, + state: State<'_, AppState>, +) -> Result { + state + .clone_opencode_profile_from_seed + .execute(request.into()) + .await + .map(ProfileDto::from) + .map_err(ErrorDto::from) +} + /// `delete_profile` — delete a profile by id. /// /// # Errors @@ -836,6 +857,73 @@ pub async fn configure_profiles( .map_err(ErrorDto::from) } +/// `list_model_servers` — list configured local model servers. +/// +/// # Errors +/// Returns an [`ErrorDto`] on registry failure. +#[tauri::command] +pub async fn list_model_servers( + state: State<'_, AppState>, +) -> Result { + state + .list_model_servers + .execute() + .await + .map(ModelServerConfigListDto::from) + .map_err(ErrorDto::from) +} + +/// `save_model_server` — upsert a local model server config. +/// +/// # Errors +/// Returns an [`ErrorDto`] on registry failure. +#[tauri::command] +pub async fn save_model_server( + request: SaveModelServerRequestDto, + state: State<'_, AppState>, +) -> Result { + let server_id = parse_model_server_id(&request.config.id)?; + let existing = state + .list_model_servers + .execute() + .await + .map_err(ErrorDto::from)? + .servers + .into_iter() + .find(|config| config.id == server_id); + let input = save_model_server_input(request, existing.as_ref())?; + state + .save_model_server + .execute(input) + .await + .map(ModelServerConfigDto::from) + .map_err(ErrorDto::from) +} + +/// `delete_model_server` — delete a local model server config when unused. +/// +/// # Errors +/// Returns `model_server_in_use` if any OpenCode profile still references it. +#[tauri::command] +pub async fn delete_model_server( + server_id: String, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let server_id = parse_model_server_id(&server_id)?; + state + .delete_model_server + .execute(application::DeleteModelServerInput { server_id }) + .await + .map_err(model_server_command_error) +} + +fn model_server_command_error(err: AppError) -> ErrorDto { + match err { + AppError::ModelServer { code, message } => ErrorDto { code, message }, + other => ErrorDto::from(other), + } +} + // --------------------------------------------------------------------------- // Embedder profiles & engines (LOT C2 — §14.5.3) // --------------------------------------------------------------------------- diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index 0bb2aea..4f027d6 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -733,11 +733,12 @@ pub struct SetActiveLayoutRequestDto { // --------------------------------------------------------------------------- use application::{ - ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, - DetectProfilesOutput, FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, - ReferenceProfilesOutput, SaveProfileInput, SaveProfileOutput, + CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput, + ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput, + FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput, + SaveProfileInput, SaveProfileOutput, }; -use domain::profile::AgentProfile; +use domain::profile::{AgentProfile, OpenCodeConfig}; use domain::ProfileId; /// A profile crossing the wire. [`AgentProfile`] already serialises camelCase @@ -776,6 +777,12 @@ impl From for ProfileDto { } } +impl From for ProfileDto { + fn from(out: CloneOpenCodeProfileFromSeedOutput) -> Self { + Self(out.profile) + } +} + /// Request DTO for `detect_profiles`: the candidate profiles to probe. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -838,6 +845,27 @@ impl From for SaveProfileInput { } } +/// Request DTO for `clone_opencode_profile_from_seed`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloneOpenCodeProfileFromSeedRequestDto { + /// Optional display name for the new profile. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Optional OpenCode config override. When omitted, the seed config is copied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opencode: Option, +} + +impl From for CloneOpenCodeProfileFromSeedInput { + fn from(dto: CloneOpenCodeProfileFromSeedRequestDto) -> Self { + Self { + name: dto.name, + opencode: dto.opencode, + } + } +} + /// Request DTO for `configure_profiles` (closes the first run). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -879,6 +907,252 @@ impl From for FirstRunStateDto { } } +// --------------------------------------------------------------------------- +// Local model servers (B35) +// --------------------------------------------------------------------------- + +use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput}; +use domain::model_server::{ + ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath, + ModelServerEndpoint, +}; +use domain::{LocalModelServerId, StopPolicy}; + +/// Local model-server implementation on the IPC wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ModelServerKindDto { + /// llama.cpp `llama-server`. + LlamaCpp, +} + +impl From for ModelServerKindDto { + fn from(kind: LocalModelServerKind) -> Self { + match kind { + LocalModelServerKind::LlamaCpp => Self::LlamaCpp, + } + } +} + +impl From for LocalModelServerKind { + fn from(kind: ModelServerKindDto) -> Self { + match kind { + ModelServerKindDto::LlamaCpp => Self::LlamaCpp, + } + } +} + +/// Stop policy on the IPC wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StopPolicyDto { + /// Keep the process alive. + KeepAlive, + /// Stop when no longer used. + StopWhenUnused, + /// Stop when the application exits. + StopOnAppExit, +} + +impl From for StopPolicyDto { + fn from(policy: StopPolicy) -> Self { + match policy { + StopPolicy::KeepAlive => Self::KeepAlive, + StopPolicy::StopWhenUnused => Self::StopWhenUnused, + StopPolicy::StopOnAppExit => Self::StopOnAppExit, + } + } +} + +impl From for StopPolicy { + fn from(policy: StopPolicyDto) -> Self { + match policy { + StopPolicyDto::KeepAlive => Self::KeepAlive, + StopPolicyDto::StopWhenUnused => Self::StopWhenUnused, + StopPolicyDto::StopOnAppExit => Self::StopOnAppExit, + } + } +} + +/// A local model-server config crossing the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelServerConfigDto { + /// Stable local server config id. + pub id: String, + /// Server implementation kind. + pub kind: ModelServerKindDto, + /// Display name. + pub name: String, + /// OpenAI-compatible base URL. + #[serde(rename = "baseURL")] + pub base_url: String, + /// TCP port. + pub port: u16, + /// Optional explicit `.gguf` model path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_path: Option, + /// Served model name exposed to OpenCode. + pub served_model_name: String, + /// Optional explicit `llama-server` executable path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binary_path: Option, + /// Extra argv entries. + #[serde(default)] + pub args: Vec, + /// Whether IdeA should start the server lazily. + pub auto_start: bool, + /// Stop policy. + pub stop_policy: StopPolicyDto, +} + +impl ModelServerConfigDto { + /// Maps a domain config to the flat IPC DTO. + #[must_use] + pub fn from_domain(config: LocalModelServerConfig) -> Self { + Self { + id: config.id.to_string(), + kind: config.kind.into(), + name: config.name, + base_url: config.endpoint.base_url, + port: config.endpoint.port, + model_path: config + .model + .path + .map(|path| path.as_str().to_owned()), + served_model_name: config.model.served_name, + binary_path: config.binary.map(|binary| binary.as_str().to_owned()), + args: config.args, + auto_start: config.auto_start, + stop_policy: config.stop_policy.into(), + } + } + + /// Maps the flat IPC DTO into the domain config, preserving internal model id + /// from the existing config when available. + /// + /// # Errors + /// [`ErrorDto`] with `INVALID` code if any domain invariant rejects the input. + pub fn into_domain( + self, + existing: Option<&LocalModelServerConfig>, + ) -> Result { + let server_id = parse_model_server_id(&self.id)?; + let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port) + .map_err(invalid_domain_error)?; + let model_path = optional_non_empty(self.model_path) + .map(ModelPath::new) + .transpose() + .map_err(invalid_domain_error)?; + let model_id = existing + .filter(|config| config.id == server_id) + .map(|config| config.model.id.clone()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let label = non_empty_or_fallback(&self.served_model_name, &self.name); + let model = LocalModelRef::new( + model_id, + label, + model_path, + self.served_model_name.clone(), + ) + .map_err(invalid_domain_error)?; + let binary = optional_non_empty(self.binary_path) + .map(ExecutablePath::new) + .transpose() + .map_err(invalid_domain_error)?; + LocalModelServerConfig::new( + server_id, + self.kind.into(), + self.name, + endpoint, + model, + binary, + self.args, + self.auto_start, + self.stop_policy.into(), + ) + .map_err(invalid_domain_error) + } +} + +/// List response for local model servers. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct ModelServerConfigListDto(pub Vec); + +impl From for ModelServerConfigListDto { + fn from(out: ListModelServersOutput) -> Self { + Self( + out.servers + .into_iter() + .map(ModelServerConfigDto::from_domain) + .collect(), + ) + } +} + +/// Request DTO for `save_model_server`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveModelServerRequestDto { + /// Config to upsert. + pub config: ModelServerConfigDto, +} + +impl From for ModelServerConfigDto { + fn from(out: SaveModelServerOutput) -> Self { + Self::from_domain(out.config) + } +} + +/// Parses a local model-server id string. +/// +/// # Errors +/// [`ErrorDto`] with `INVALID` code if the string is not a UUID. +pub fn parse_model_server_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(LocalModelServerId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid model server id: {raw}"), + }) +} + +/// Builds a save-model-server input after the caller has resolved the existing +/// config, if any. +/// +/// # Errors +/// [`ErrorDto`] if DTO-to-domain validation fails. +pub fn save_model_server_input( + request: SaveModelServerRequestDto, + existing: Option<&LocalModelServerConfig>, +) -> Result { + Ok(SaveModelServerInput { + config: request.config.into_domain(existing)?, + }) +} + +fn optional_non_empty(raw: Option) -> Option { + raw.map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) +} + +fn non_empty_or_fallback(primary: &str, fallback: &str) -> String { + let primary = primary.trim(); + if primary.is_empty() { + fallback.trim().to_owned() + } else { + primary.to_owned() + } +} + +fn invalid_domain_error(error: impl std::fmt::Display) -> ErrorDto { + ErrorDto { + code: "INVALID".to_owned(), + message: error.to_string(), + } +} + // --------------------------------------------------------------------------- // Embedder profiles & engines (LOT C2 — §14.5.3) // --------------------------------------------------------------------------- diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index 3d5f43d..6a33d92 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -15,11 +15,81 @@ use tauri::{AppHandle, Emitter}; use domain::conversation::ConversationParty; use domain::events::{DomainEvent, OrchestrationSource}; use domain::input::AgentLiveness; +use domain::model_server::ModelServerLifecycleStatus; use domain::{IssueLinkKind, IssuePriority, IssueStatus}; use infrastructure::TokioBroadcastEventBus; /// Name of the Tauri event carrying relayed [`DomainEvent`]s. pub const DOMAIN_EVENT: &str = "domain://event"; +/// Dedicated Tauri event for local model-server status changes. +pub const MODEL_SERVER_STATUS_CHANGED: &str = "model_server_status_changed"; +/// Dedicated Tauri event for launch failures. +pub const AGENT_LAUNCH_FAILED: &str = "agent_launch_failed"; + +/// Model-server lifecycle status on the Tauri wire. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum ModelServerStatusDto { + /// No server is configured for this profile. + NotConfigured, + /// Readiness probing is in progress. + Probing, + /// IdeA is starting the process. + Starting, + /// Server is ready. + #[serde(rename_all = "camelCase")] + Ready { + /// `true` when an existing server was reused. + reused: bool, + }, + /// Server preparation failed. + #[serde(rename_all = "camelCase")] + Failed { + /// Stable failure code. + code: String, + /// Human-readable message. + message: String, + }, +} + +impl From<&ModelServerLifecycleStatus> for ModelServerStatusDto { + fn from(status: &ModelServerLifecycleStatus) -> Self { + match status { + ModelServerLifecycleStatus::NotConfigured => Self::NotConfigured, + ModelServerLifecycleStatus::Probing => Self::Probing, + ModelServerLifecycleStatus::Starting => Self::Starting, + ModelServerLifecycleStatus::Ready { reused } => Self::Ready { reused: *reused }, + ModelServerLifecycleStatus::Failed { code, message } => Self::Failed { + code: code.clone(), + message: message.clone(), + }, + } + } +} + +/// Payload for [`MODEL_SERVER_STATUS_CHANGED`]. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelServerStatusChangedDto { + /// Local model server id. + pub server_id: String, + /// Lifecycle status. + pub status: ModelServerStatusDto, +} + +/// Payload for [`AGENT_LAUNCH_FAILED`]. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentLaunchFailedDto { + /// Agent id. + pub agent_id: String, + /// Failure cause namespace. + pub cause: String, + /// Stable error code. + pub code: String, + /// Human-readable message. + pub message: String, +} /// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged). /// @@ -42,6 +112,26 @@ pub enum DomainEventDto { /// Session id. session_id: String, }, + /// An agent launch failed before runtime session creation. + #[serde(rename_all = "camelCase")] + AgentLaunchFailed { + /// Agent id. + agent_id: String, + /// Failure cause namespace. + cause: String, + /// Stable error code. + code: String, + /// Human-readable message. + message: String, + }, + /// Local model-server status changed. + #[serde(rename_all = "camelCase")] + ModelServerStatusChanged { + /// Local model server id. + server_id: String, + /// Lifecycle status. + status: ModelServerStatusDto, + }, /// An agent exited. #[serde(rename_all = "camelCase")] AgentExited { @@ -571,6 +661,23 @@ impl From<&DomainEvent> for DomainEventDto { agent_id: agent_id.to_string(), session_id: session_id.to_string(), }, + DomainEvent::AgentLaunchFailed { + agent_id, + cause, + code, + message, + } => Self::AgentLaunchFailed { + agent_id: agent_id.to_string(), + cause: cause.clone(), + code: code.clone(), + message: message.clone(), + }, + DomainEvent::ModelServerStatusChanged { server_id, status } => { + Self::ModelServerStatusChanged { + server_id: server_id.to_string(), + status: status.into(), + } + } DomainEvent::AgentExited { agent_id, code } => Self::AgentExited { agent_id: agent_id.to_string(), code: *code, @@ -993,6 +1100,34 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) { } let dto = DomainEventDto::from(&event); let _ = app.emit(DOMAIN_EVENT, dto); + match &event { + DomainEvent::ModelServerStatusChanged { server_id, status } => { + let _ = app.emit( + MODEL_SERVER_STATUS_CHANGED, + ModelServerStatusChangedDto { + server_id: server_id.to_string(), + status: status.into(), + }, + ); + } + DomainEvent::AgentLaunchFailed { + agent_id, + cause, + code, + message, + } => { + let _ = app.emit( + AGENT_LAUNCH_FAILED, + AgentLaunchFailedDto { + agent_id: agent_id.to_string(), + cause: cause.clone(), + code: code.clone(), + message: message.clone(), + }, + ); + } + _ => {} + } } // The bus dropped some events for this slow receiver; keep going. Err(RecvError::Lagged(_)) => continue, @@ -1008,13 +1143,47 @@ mod tests { use super::*; use domain::ids::AgentId; use domain::mailbox::TicketId; - use domain::ProjectId; + use domain::{LocalModelServerId, ProjectId}; use serde_json::json; fn agent(n: u128) -> AgentId { AgentId::from_uuid(uuid::Uuid::from_u128(n)) } + fn server(n: u128) -> LocalModelServerId { + LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n)) + } + + #[test] + fn model_server_status_changed_relays_ready_to_dto_and_wire() { + let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged { + server_id: server(35), + status: ModelServerLifecycleStatus::Ready { reused: true }, + }); + + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "modelServerStatusChanged"); + assert_eq!(json["serverId"], server(35).to_string()); + assert_eq!(json["status"]["state"], "ready"); + assert_eq!(json["status"]["reused"], true); + } + + #[test] + fn agent_launch_failed_relays_model_server_cause() { + let dto = DomainEventDto::from(&DomainEvent::AgentLaunchFailed { + agent_id: agent(36), + cause: "model_server".to_owned(), + code: "path_not_accessible".to_owned(), + message: "path not accessible: /models/missing.gguf".to_owned(), + }); + + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "agentLaunchFailed"); + assert_eq!(json["agentId"], agent(36).to_string()); + assert_eq!(json["cause"], "model_server"); + assert_eq!(json["code"], "path_not_accessible"); + } + /// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO /// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que /// le front badge). Garantit le câblage présentation de la détection de stall. diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index a578e40..fca719d 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -106,6 +106,8 @@ pub fn run() { // If we killed first, the registry would be empty and every // agent would be persisted as "closed". let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents); + let model_servers = + std::sync::Arc::clone(&state.ensure_local_model_server); let open_projects = state.open_project_ids(); let handles = state.terminal_sessions.handles(); tauri::async_runtime::block_on(async move { @@ -119,6 +121,7 @@ pub fn run() { for h in handles { let _ = pty.kill(&h).await; } + let _ = model_servers.stop_on_app_exit().await; }); } } @@ -156,8 +159,12 @@ pub fn run() { commands::detect_profiles, commands::list_profiles, commands::save_profile, + commands::clone_opencode_profile_from_seed, commands::delete_profile, commands::configure_profiles, + commands::list_model_servers, + commands::save_model_server, + commands::delete_model_server, commands::list_embedder_profiles, commands::save_embedder_profile, commands::delete_embedder_profile, diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 2bd8e17..1ba18fe 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -14,32 +14,32 @@ use std::sync::{Arc, Mutex}; use application::{ AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask, - ChangeAgentProfile, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, - CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, - CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, - CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, - DeleteMemory, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, - DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, - FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState, - GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, - GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent, - LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListIssues, - ListLayouts, ListMemories, ListProfiles, ListProjects, ListResumableAgents, ListSkills, - ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, - LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, MoveTabToNewWindow, - MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant, - OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, - ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex, - ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState, - ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, - RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, - RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, SaveProfile, - SessionLimitService, SetActiveLayout, SnapshotRunningAgents, SpawnBackgroundCommand, - StopLiveAgent, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, - TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, - UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, - UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, - WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, CloseProject, + CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, + CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, + CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, + DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill, + DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, + DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory, + GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, + GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, + InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, + ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, + ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, + LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, + McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, + OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext, + ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, + ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, + ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, + RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, + ResolveMemoryLinks, RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, + SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents, + SpawnBackgroundCommand, StopLiveAgent, StructuredSessions, SuggestedThisSession, + SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, + UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, + UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, + UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ @@ -69,10 +69,11 @@ use infrastructure::{ CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe, FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, - FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore, - FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, Git2Repository, - HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, - LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, + FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, + FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, + Git2Repository, HeuristicHandoffSummarizer, HttpOpenAiCompatibleProbe, IdeaiContextStore, + InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime, LocalFileSystem, + LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, @@ -776,6 +777,8 @@ pub struct AppState { pub list_profiles: Arc, /// Save (upsert) a profile. pub save_profile: Arc, + /// Create a new OpenCode profile instance from the canonical seed. + pub clone_opencode_profile_from_seed: Arc, /// Delete a profile. pub delete_profile: Arc, /// Persist the batch of chosen profiles (closes the first run). @@ -784,6 +787,14 @@ pub struct AppState { pub reference_profiles: Arc, /// Whether the first-run wizard should show + the reference catalogue. pub first_run_state: Arc, + /// Ensures local llama.cpp model servers for OpenCode profiles. + pub ensure_local_model_server: Arc, + /// Lists local model server configurations. + pub list_model_servers: Arc, + /// Saves local model server configurations. + pub save_model_server: Arc, + /// Deletes local model server configurations when unused. + pub delete_model_server: Arc, /// The local PTY adapter, kept port-typed so the presentation layer can /// `subscribe_output` to pump bytes into the [`PtyBridge`] (it owns transport). pub pty_port: Arc, @@ -1183,11 +1194,41 @@ impl AppState { let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port))); let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port))); let save_profile = Arc::new(SaveProfile::new(Arc::clone(&profile_store_port))); + let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new( + Arc::clone(&profile_store_port), + Arc::clone(&ids) as Arc, + )); let delete_profile = Arc::new(DeleteProfile::new(Arc::clone(&profile_store_port))); let configure_profiles = Arc::new(ConfigureProfiles::new(Arc::clone(&profile_store_port))); let reference_profiles = Arc::new(ReferenceProfiles::new()); let first_run_state = Arc::new(FirstRunState::new(Arc::clone(&profile_store_port))); + let model_server_registry = Arc::new(FsModelServerRegistry::new( + Arc::clone(&fs_port), + app_data_dir.to_string_lossy().into_owned(), + )); + let ensure_local_model_server = Arc::new(EnsureLocalModelServer::new( + Arc::clone(&model_server_registry) as Arc, + Arc::new(HttpOpenAiCompatibleProbe::default()) + as Arc, + Arc::new(LocalManagedProcess::new()) as Arc, + Arc::new(LlamaCppRuntime::new()) as Arc, + Arc::clone(&fs_port), + Arc::clone(&events_port), + )); + let model_server_registry_port = + Arc::clone(&model_server_registry) as Arc; + let list_model_servers = Arc::new(ListModelServers::new(Arc::clone( + &model_server_registry_port, + ))); + let save_model_server = Arc::new(SaveModelServer::new(Arc::clone( + &model_server_registry_port, + ))); + let delete_model_server = Arc::new(DeleteModelServer::new( + Arc::clone(&model_server_registry_port), + Arc::clone(&profile_store_port), + )); + let pty_bridge = Arc::new(PtyBridge::new()); // Twin of the PTY bridge for structured chat sessions (§17.7): routes a // turn's ReplyChunks to the owning chat cell and retains the conversation @@ -1499,7 +1540,8 @@ impl AppState { // parse ⇒ section omise, jamais d'échec de lancement. .with_live_state_lean(Arc::new(AppLiveStateLeanProvider { clock: Arc::clone(&clock) as Arc, - }) as Arc), + }) as Arc) + .with_local_model_server(Arc::clone(&ensure_local_model_server)), ); // Inter-agent launcher: same context, memory, permissions and live-state @@ -1530,6 +1572,7 @@ impl AppState { .with_live_state_lean(Arc::new(AppLiveStateLeanProvider { clock: Arc::clone(&clock) as Arc, }) as Arc) + .with_local_model_server(Arc::clone(&ensure_local_model_server)) .with_structured( Arc::clone(&session_factory), Arc::clone(&structured_sessions), @@ -2196,10 +2239,15 @@ impl AppState { detect_profiles, list_profiles, save_profile, + clone_opencode_profile_from_seed, delete_profile, configure_profiles, reference_profiles, first_run_state, + ensure_local_model_server, + list_model_servers, + save_model_server, + delete_model_server, pty_port, terminal_sessions, event_bus, diff --git a/crates/app-tauri/tests/dto_model_servers.rs b/crates/app-tauri/tests/dto_model_servers.rs new file mode 100644 index 0000000..81dc85e --- /dev/null +++ b/crates/app-tauri/tests/dto_model_servers.rs @@ -0,0 +1,111 @@ +//! DTO contract tests for local model-server IPC. + +use app_tauri_lib::dto::{ + ModelServerConfigDto, ModelServerKindDto, SaveModelServerRequestDto, StopPolicyDto, + save_model_server_input, +}; +use domain::model_server::{ + ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath, + ModelServerEndpoint, +}; +use domain::{LocalModelServerId, StopPolicy}; +use serde_json::json; +use uuid::Uuid; + +fn sid(n: u128) -> LocalModelServerId { + LocalModelServerId::from_uuid(Uuid::from_u128(n)) +} + +fn domain_config(id: LocalModelServerId, model_id: &str) -> LocalModelServerConfig { + LocalModelServerConfig::new( + id, + LocalModelServerKind::LlamaCpp, + "Local Qwen", + ModelServerEndpoint::new("http://localhost:8080", 8080).unwrap(), + LocalModelRef::new( + model_id, + "Qwen", + Some(ModelPath::new("/models/qwen.gguf").unwrap()), + "qwen3-coder", + ) + .unwrap(), + Some(ExecutablePath::new("/usr/bin/llama-server").unwrap()), + vec!["--ctx-size".to_owned(), "4096".to_owned()], + true, + StopPolicy::StopOnAppExit, + ) + .unwrap() +} + +#[test] +fn model_server_dto_serialises_flat_camelcase_wire_shape() { + let dto = ModelServerConfigDto::from_domain(domain_config(sid(35), "internal-model-id")); + + let value = serde_json::to_value(dto).unwrap(); + + assert_eq!(value["id"], sid(35).to_string()); + assert_eq!(value["kind"], "llamaCpp"); + assert_eq!(value["baseURL"], "http://localhost:8080/v1"); + assert_eq!(value["modelPath"], "/models/qwen.gguf"); + assert_eq!(value["servedModelName"], "qwen3-coder"); + assert_eq!(value["binaryPath"], "/usr/bin/llama-server"); + assert_eq!(value["stopPolicy"], "stopOnAppExit"); + assert!(value.get("model").is_none(), "domain model must not leak"); + assert!(value.get("endpoint").is_none(), "domain endpoint must not leak"); +} + +#[test] +fn model_server_dto_deserialises_flat_wire_shape_and_generates_model_id() { + let raw = json!({ + "id": sid(36).to_string(), + "kind": "llamaCpp", + "name": "Local Qwen", + "baseURL": "http://localhost:8081", + "port": 8081, + "modelPath": "/models/qwen.gguf", + "servedModelName": "qwen3-coder", + "binaryPath": "/usr/bin/llama-server", + "args": ["--ctx-size", "4096"], + "autoStart": true, + "stopPolicy": "stopOnAppExit" + }); + + let request = SaveModelServerRequestDto { + config: serde_json::from_value(raw).unwrap(), + }; + let input = save_model_server_input(request, None).unwrap(); + + assert_eq!(input.config.id, sid(36)); + assert_eq!(input.config.kind, LocalModelServerKind::LlamaCpp); + assert_eq!(input.config.endpoint.base_url, "http://localhost:8081/v1"); + assert_eq!( + input.config.model.path.unwrap().as_str(), + "/models/qwen.gguf" + ); + assert_eq!(input.config.model.served_name, "qwen3-coder"); + assert_eq!(input.config.model.label, "qwen3-coder"); + assert!(!input.config.model.id.is_empty()); +} + +#[test] +fn model_server_dto_preserves_existing_internal_model_id_on_upsert() { + let existing = domain_config(sid(37), "stable-internal-id"); + let dto = ModelServerConfigDto { + id: sid(37).to_string(), + kind: ModelServerKindDto::LlamaCpp, + name: "Updated Qwen".to_owned(), + base_url: "http://localhost:8082/v1".to_owned(), + port: 8082, + model_path: Some("/models/qwen-updated.gguf".to_owned()), + served_model_name: "qwen3-coder-updated".to_owned(), + binary_path: Some("/usr/bin/llama-server".to_owned()), + args: Vec::new(), + auto_start: true, + stop_policy: StopPolicyDto::StopOnAppExit, + }; + + let config = dto.into_domain(Some(&existing)).unwrap(); + + assert_eq!(config.model.id, "stable-internal-id"); + assert_eq!(config.model.served_name, "qwen3-coder-updated"); +} diff --git a/crates/app-tauri/tests/dto_profiles.rs b/crates/app-tauri/tests/dto_profiles.rs index b7fa914..1615d28 100644 --- a/crates/app-tauri/tests/dto_profiles.rs +++ b/crates/app-tauri/tests/dto_profiles.rs @@ -3,15 +3,16 @@ //! error behaviour. use app_tauri_lib::dto::{ - parse_delete_profile, parse_profile_id, ConfigureProfilesRequestDto, DetectProfilesRequestDto, - DetectProfilesResponseDto, FirstRunStateDto, ProfileListDto, SaveProfileRequestDto, + parse_delete_profile, parse_profile_id, CloneOpenCodeProfileFromSeedRequestDto, + ConfigureProfilesRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, + FirstRunStateDto, ProfileListDto, SaveProfileRequestDto, }; use application::{ - ConfigureProfilesInput, DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput, - ProfileAvailability, SaveProfileInput, + CloneOpenCodeProfileFromSeedInput, ConfigureProfilesInput, DetectProfilesInput, + DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability, SaveProfileInput, }; -use domain::ids::ProfileId; -use domain::profile::{AgentProfile, ContextInjection}; +use domain::ids::{LocalModelServerId, ProfileId}; +use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig}; use serde_json::json; use uuid::Uuid; @@ -94,6 +95,47 @@ fn save_request_deserialises_profile() { assert!(input.profile.detect.is_none()); } +#[test] +fn clone_opencode_profile_from_seed_request_deserialises_camelcase_config() { + let server_id = LocalModelServerId::from_uuid(Uuid::from_u128(35)); + let raw = json!({ + "name": "Local Qwen", + "opencode": { + "baseURL": "http://localhost:8081/v1", + "model": "qwen3-coder-70b", + "reasoning": false, + "attachment": true, + "localModelServerId": server_id.to_string() + } + }); + + let dto: CloneOpenCodeProfileFromSeedRequestDto = serde_json::from_value(raw).unwrap(); + let input: CloneOpenCodeProfileFromSeedInput = dto.into(); + assert_eq!(input.name.as_deref(), Some("Local Qwen")); + let opencode = input.opencode.expect("config override"); + assert_eq!(opencode.base_url, "http://localhost:8081/v1"); + assert_eq!(opencode.api_key, None); + assert_eq!(opencode.model, "qwen3-coder-70b"); + assert_eq!(opencode.reasoning, Some(false)); + assert_eq!(opencode.attachment, Some(true)); + assert_eq!(opencode.local_model_server_id, Some(server_id)); +} + +#[test] +fn opencode_config_dto_omits_local_model_server_id_when_none() { + let config = OpenCodeConfig::new( + "http://localhost:8080/v1", + None, + "qwen3-coder-30b", + None, + None, + ) + .unwrap(); + + let value = serde_json::to_value(&config).unwrap(); + assert!(value.get("localModelServerId").is_none()); +} + #[test] fn configure_request_deserialises_profiles() { let raw = json!({ "profiles": [] }); diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 4f1ff5e..c30c908 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -35,6 +35,7 @@ use domain::live_state::WorkStatus; use crate::error::AppError; use crate::layout::{persist_doc, resolve_doc}; +use crate::model_server::{EnsureLocalModelServer, EnsureLocalModelServerInput}; use crate::project::project_context_path; use crate::terminal::{StructuredSessions, TerminalSessions}; use crate::workstate::GetLiveStateLean; @@ -1136,6 +1137,10 @@ pub struct LaunchAgent { /// injection (zéro régression). Best-effort strict : provider absent / erreur / /// parse ⇒ section omise, jamais d'échec de lancement. live_state_lean: Option>, + /// Ensure local model servers referenced by OpenCode profiles before writing + /// `opencode.json` (B35). Optional for legacy tests/wiring; required at runtime + /// when `OpenCodeConfig.localModelServerId` is set. + local_model_server: Option>, } impl LaunchAgent { @@ -1174,9 +1179,17 @@ impl LaunchAgent { provider_sessions: None, projectors: None, live_state_lean: None, + local_model_server: None, } } + /// Injects the local model-server ensure use case used by OpenCode profiles. + #[must_use] + pub fn with_local_model_server(mut self, ensure: Arc) -> Self { + self.local_model_server = Some(ensure); + self + } + /// Branche le provider de **live-state lean (lot LS4)** : au lancement, l'aperçu /// `# État du projet` (status + intent des autres agents) est injecté dans le /// convention file. Sans cet appel (cas legacy / tests existants), aucune section @@ -1550,7 +1563,7 @@ impl LaunchAgent { .contexts .read_context(&input.project, &agent.id) .await?; - let profile = self + let mut profile = self .profiles .list() .await? @@ -1649,6 +1662,9 @@ impl LaunchAgent { ) .await?; + self.ensure_local_model_server_for_opencode(&agent, &mut profile) + .await?; + // 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ── // Strictement APRÈS le convention file (étape 5) et AVANT le spawn / // `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`, @@ -2336,6 +2352,60 @@ impl LaunchAgent { } } } + + async fn ensure_local_model_server_for_opencode( + &self, + agent: &Agent, + profile: &mut AgentProfile, + ) -> Result<(), AppError> { + if profile.structured_adapter != Some(StructuredAdapter::OpenCode) { + return Ok(()); + } + let Some(opencode) = profile.opencode.as_mut() else { + return Ok(()); + }; + let Some(server_id) = opencode.local_model_server_id else { + return Ok(()); + }; + let Some(ensure) = self.local_model_server.as_ref() else { + let err = AppError::ModelServer { + code: "not_wired".to_owned(), + message: + "OpenCode profile references localModelServerId but no model server service is wired" + .to_owned(), + }; + self.publish_agent_launch_failed(agent.id, &err); + return Err(err); + }; + + match ensure + .execute(EnsureLocalModelServerInput { server_id }) + .await + { + Ok(output) => { + opencode.base_url = output.ready.base_url; + opencode.model = output.ready.model; + Ok(()) + } + Err(err) => { + self.publish_agent_launch_failed(agent.id, &err); + Err(err) + } + } + } + + fn publish_agent_launch_failed(&self, agent_id: AgentId, err: &AppError) { + let (cause, code) = match err { + AppError::ModelServer { code, .. } => ("model_server", code.as_str()), + _ => ("launch", err.code()), + }; + self.events.publish(DomainEvent::AgentLaunchFailed { + agent_id, + cause: cause.to_owned(), + code: code.to_owned(), + message: err.to_string(), + }); + } } /// Outcome of the R0a discrimination between a legitimate **view reattach** and a diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 8ba986d..7a11e5b 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -41,8 +41,10 @@ pub use resume::{ ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent, }; pub use usecases::{ - ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile, - DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, - FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, ReferenceProfiles, - ReferenceProfilesOutput, SaveProfile, SaveProfileInput, SaveProfileOutput, + CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput, + CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, + ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles, + DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles, + ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput, + SaveProfile, SaveProfileInput, SaveProfileOutput, }; diff --git a/crates/application/src/agent/usecases.rs b/crates/application/src/agent/usecases.rs index 3ce0d03..403d1b0 100644 --- a/crates/application/src/agent/usecases.rs +++ b/crates/application/src/agent/usecases.rs @@ -13,12 +13,13 @@ use std::sync::Arc; -use domain::ports::{AgentRuntime, ProfileStore}; -use domain::profile::AgentProfile; +use domain::ids::ProfileId; +use domain::ports::{AgentRuntime, IdGenerator, ProfileStore}; +use domain::profile::{AgentProfile, OpenCodeConfig, StructuredAdapter}; use crate::error::AppError; -use super::catalogue::selectable_reference_profiles; +use super::catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles}; // --------------------------------------------------------------------------- // DetectProfiles @@ -133,6 +134,111 @@ pub struct SaveProfileOutput { pub profile: AgentProfile, } +// --------------------------------------------------------------------------- +// CloneOpenCodeProfileFromSeed +// --------------------------------------------------------------------------- + +/// Input for [`CloneOpenCodeProfileFromSeed::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CloneOpenCodeProfileFromSeedInput { + /// Optional display name for the cloned profile. When absent, a copy label is + /// derived from the seed name. + pub name: Option, + /// Optional OpenCode config override. When absent, the seed config is copied. + pub opencode: Option, +} + +/// Output of [`CloneOpenCodeProfileFromSeed::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CloneOpenCodeProfileFromSeedOutput { + /// The newly persisted profile. + pub profile: AgentProfile, +} + +/// Creates a new OpenCode profile instance from the canonical seed/template. +/// +/// The persisted canonical `opencode-llamacpp` profile is preferred when present, +/// so local edits are preserved as the clone template. If it is absent, the +/// in-memory reference catalogue seed is used. The new profile always receives a +/// fresh [`ProfileId`], which is the only identity constraint; multiple profiles +/// with `StructuredAdapter::OpenCode` are therefore valid. +pub struct CloneOpenCodeProfileFromSeed { + store: Arc, + ids: Arc, +} + +impl CloneOpenCodeProfileFromSeed { + /// Builds the use case from the profile store and id generator ports. + #[must_use] + pub fn new(store: Arc, ids: Arc) -> Self { + Self { store, ids } + } + + /// Clones the canonical OpenCode seed into a new persisted profile. + /// + /// # Errors + /// [`AppError::Store`] on persistence failure, [`AppError::Invalid`] if the + /// requested name is blank, or [`AppError::Internal`] if the seed is malformed. + pub async fn execute( + &self, + input: CloneOpenCodeProfileFromSeedInput, + ) -> Result { + let existing = self.store.list().await?; + let seed_id = reference_profile_id("opencode-llamacpp"); + let seed = existing + .iter() + .find(|profile| profile.id == seed_id) + .cloned() + .or_else(|| { + reference_profiles() + .into_iter() + .find(|profile| profile.id == seed_id) + }) + .ok_or_else(|| { + AppError::Internal("canonical OpenCode seed `opencode-llamacpp` is missing".into()) + })?; + + if seed.structured_adapter != Some(StructuredAdapter::OpenCode) || seed.opencode.is_none() { + return Err(AppError::Internal( + "canonical OpenCode seed is not an OpenCode profile".into(), + )); + } + + let mut profile = seed; + profile.id = fresh_profile_id(&*self.ids, &existing)?; + profile.name = match input.name { + Some(name) => { + if name.trim().is_empty() { + return Err(AppError::Invalid("profile.name must not be empty".into())); + } + name + } + None => format!("{} copy", profile.name), + }; + if let Some(config) = input.opencode { + profile.opencode = Some(config); + } + + self.store.save(&profile).await?; + Ok(CloneOpenCodeProfileFromSeedOutput { profile }) + } +} + +fn fresh_profile_id( + ids: &dyn IdGenerator, + existing: &[AgentProfile], +) -> Result { + for _ in 0..16 { + let id = ProfileId::from_uuid(ids.new_uuid()); + if existing.iter().all(|profile| profile.id != id) { + return Ok(id); + } + } + Err(AppError::Internal( + "could not allocate a unique profile id".into(), + )) +} + /// Persists (creates or replaces) a single profile. pub struct SaveProfile { store: Arc, diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs index 07a3be3..f4fae74 100644 --- a/crates/application/src/error.rs +++ b/crates/application/src/error.rs @@ -6,8 +6,8 @@ //! with one error shape when building its `ErrorDTO`. use domain::ports::{ - AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, - RemoteError, RuntimeError, StoreError, + AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ModelServerError, + ProcessError, PtyError, RemoteError, RuntimeError, StoreError, }; use domain::{AgentId, NodeId}; use domain::{IssueStoreError, SprintStoreError}; @@ -38,6 +38,15 @@ pub enum AppError { #[error("process error: {0}")] Process(String), + /// A local model server could not be prepared for launch. + #[error("model server error ({code}): {message}")] + ModelServer { + /// Stable model-server error code. + code: String, + /// Human-readable message. + message: String, + }, + /// A git operation failed. #[error("git error: {0}")] Git(String), @@ -101,6 +110,7 @@ impl AppError { Self::FileSystem(_) => "FILESYSTEM", Self::Store(_) => "STORE", Self::Process(_) => "PROCESS", + Self::ModelServer { .. } => "MODEL_SERVER", Self::Git(_) => "GIT", Self::Remote(_) => "REMOTE", Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING", @@ -183,6 +193,30 @@ impl From for AppError { } } +impl From for AppError { + fn from(e: ModelServerError) -> Self { + Self::ModelServer { + code: model_server_error_code(&e).to_owned(), + message: e.to_string(), + } + } +} + +fn model_server_error_code(err: &ModelServerError) -> &'static str { + match err { + ModelServerError::NotConfigured => "not_configured", + ModelServerError::Invalid(_) => "invalid", + ModelServerError::PermissionDenied(_) => "permission_denied", + ModelServerError::PathNotAccessible(_) => "path_not_accessible", + ModelServerError::PortOccupied(_) => "port_occupied", + ModelServerError::InUse(_) => "model_server_in_use", + ModelServerError::Probe(_) => "probe", + ModelServerError::Process(_) => "process", + ModelServerError::Store(_) => "store", + ModelServerError::Timeout => "timeout", + } +} + impl From for AppError { fn from(e: RuntimeError) -> Self { Self::Process(e.to_string()) diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 1719ba5..2b36d12 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -22,6 +22,7 @@ pub mod health; pub mod issues; pub mod layout; pub mod memory; +pub mod model_server; pub mod orchestrator; pub mod permission; pub mod project; @@ -39,19 +40,20 @@ pub use agent::{ drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id, reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer, AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, - ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, - CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, - DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, - FirstRunStateOutput, HandoffProvider, InjectedLiveRow, InspectConversation, - InspectConversationInput, InspectConversationOutput, LaunchAgent, LaunchAgentInput, - LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListProfiles, - ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, - LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry, ProfileAvailability, - ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, - ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput, - SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome, - UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, - LIVE_STATE_INJECT_MAX, RESUME_PROMPT, + CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput, + CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, + ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, + DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles, + DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider, + InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput, + LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, + ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents, + ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime, + PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext, + ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, + ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService, + StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput, + AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT, }; pub use background::{ BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput, @@ -102,6 +104,12 @@ pub use memory::{ RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, }; +pub use model_server::{ + model_server_error_code, DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer, + EnsureLocalModelServerInput, EnsureLocalModelServerOutput, ListModelServers, + ListModelServersOutput, ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer, + SaveModelServerInput, SaveModelServerOutput, +}; pub use orchestrator::{ resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog, AgentWakeService, AskLivenessProbe, ContextGuardUseCases, LiveStateProvider, diff --git a/crates/application/src/model_server.rs b/crates/application/src/model_server.rs new file mode 100644 index 0000000..4b3958c --- /dev/null +++ b/crates/application/src/model_server.rs @@ -0,0 +1,447 @@ +//! Use cases for local model servers. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use domain::events::DomainEvent; +use domain::model_server::{ + LocalModelServerConfig, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus, +}; +use domain::ports::{ + EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe, + ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore, RemotePath, +}; +use domain::{LocalModelServerId, StopPolicy}; + +use crate::error::AppError; + +/// Output of [`ListModelServers::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListModelServersOutput { + /// Persisted local model-server configs. + pub servers: Vec, +} + +/// Lists local model-server configurations. +pub struct ListModelServers { + registry: Arc, +} + +impl ListModelServers { + /// Builds the use case. + #[must_use] + pub fn new(registry: Arc) -> Self { + Self { registry } + } + + /// Lists configs. + /// + /// # Errors + /// [`AppError::ModelServer`] on registry failure. + pub async fn execute(&self) -> Result { + Ok(ListModelServersOutput { + servers: self.registry.list().await?, + }) + } +} + +/// Input for [`SaveModelServer::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SaveModelServerInput { + /// Config to upsert by id. + pub config: LocalModelServerConfig, +} + +/// Output of [`SaveModelServer::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SaveModelServerOutput { + /// Saved config. + pub config: LocalModelServerConfig, +} + +/// Saves a local model-server configuration. +pub struct SaveModelServer { + registry: Arc, +} + +impl SaveModelServer { + /// Builds the use case. + #[must_use] + pub fn new(registry: Arc) -> Self { + Self { registry } + } + + /// Saves a config. + /// + /// # Errors + /// [`AppError::ModelServer`] on registry failure. + pub async fn execute( + &self, + input: SaveModelServerInput, + ) -> Result { + self.registry.save(input.config.clone()).await?; + Ok(SaveModelServerOutput { + config: input.config, + }) + } +} + +/// Input for [`DeleteModelServer::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeleteModelServerInput { + /// Config id to delete. + pub server_id: LocalModelServerId, +} + +/// Deletes a local model-server config when no OpenCode profile still references it. +pub struct DeleteModelServer { + registry: Arc, + profiles: Arc, +} + +impl DeleteModelServer { + /// Builds the use case. + #[must_use] + pub fn new(registry: Arc, profiles: Arc) -> Self { + Self { registry, profiles } + } + + /// Deletes a config after checking profile references. + /// + /// # Errors + /// [`AppError::ModelServer`] with `code=model_server_in_use` when referenced. + pub async fn execute(&self, input: DeleteModelServerInput) -> Result<(), AppError> { + let profiles = self.profiles.list().await?; + if profiles.iter().any(|profile| { + profile + .opencode + .as_ref() + .and_then(|config| config.local_model_server_id) + == Some(input.server_id) + }) { + return Err(ModelServerError::InUse(input.server_id.to_string()).into()); + } + self.registry.delete(input.server_id).await?; + Ok(()) + } +} + +/// Input for [`EnsureLocalModelServer::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnsureLocalModelServerInput { + /// Referenced local model server. + pub server_id: LocalModelServerId, +} + +/// Output of [`EnsureLocalModelServer::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnsureLocalModelServerOutput { + /// Ready server data to inject into OpenCode config. + pub ready: ModelServerReady, +} + +/// Readiness retry policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReadinessPolicy { + /// Number of probes after spawning. + pub attempts: usize, + /// Delay between attempts. + pub backoff: Duration, +} + +impl Default for ReadinessPolicy { + fn default() -> Self { + Self { + attempts: 20, + backoff: Duration::from_millis(250), + } + } +} + +#[derive(Debug, Clone)] +struct ActiveServer { + handle: ManagedProcessHandle, + port: u16, + stop_policy: StopPolicy, +} + +/// Ensures a configured local model server is reachable, starting it when allowed. +pub struct EnsureLocalModelServer { + registry: Arc, + probe: Arc, + process: Arc, + runtime: Arc, + fs: Arc, + events: Arc, + active: Mutex>, + readiness: ReadinessPolicy, +} + +impl EnsureLocalModelServer { + /// Builds the use case. + #[allow(clippy::too_many_arguments)] + #[must_use] + pub fn new( + registry: Arc, + probe: Arc, + process: Arc, + runtime: Arc, + fs: Arc, + events: Arc, + ) -> Self { + Self { + registry, + probe, + process, + runtime, + fs, + events, + active: Mutex::new(HashMap::new()), + readiness: ReadinessPolicy::default(), + } + } + + /// Overrides readiness policy, mainly for tests. + #[must_use] + pub fn with_readiness_policy(mut self, readiness: ReadinessPolicy) -> Self { + self.readiness = readiness; + self + } + + /// Ensures the server is reachable. + /// + /// # Errors + /// [`AppError::ModelServer`] if the server cannot be prepared. + pub async fn execute( + &self, + input: EnsureLocalModelServerInput, + ) -> Result { + let config = self + .registry + .get(&input.server_id) + .await? + .ok_or(ModelServerError::NotConfigured)?; + + self.publish(config.id, ModelServerLifecycleStatus::Probing); + let initial_probe = match self.probe.probe(&config.endpoint).await { + Ok(status) => status, + Err(err) => return self.fail(config.id, err), + }; + match initial_probe { + ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted => { + self.publish( + config.id, + ModelServerLifecycleStatus::Ready { reused: true }, + ); + return Ok(EnsureLocalModelServerOutput { + ready: ready(&config, ModelServerStatus::ReadyReused), + }); + } + ModelServerStatus::Unreachable => {} + } + + if !config.auto_start { + let err = ModelServerError::Probe("server unreachable and autoStart=false".to_owned()); + self.publish_failure(config.id, &err); + return Err(err.into()); + } + + self.ensure_model_path_accessible(&config).await?; + self.ensure_no_active_port_collision(&config).await?; + + self.publish(config.id, ModelServerLifecycleStatus::Starting); + let spec = match self.runtime.build_spawn_spec(&config) { + Ok(spec) => spec, + Err(err) => return self.fail(config.id, err), + }; + let handle = match self.process.spawn(spec).await { + Ok(handle) => handle, + Err(err) => return self.fail(config.id, err), + }; + self.active.lock().unwrap().insert( + config.id, + ActiveServer { + handle: handle.clone(), + port: config.endpoint.port, + stop_policy: config.stop_policy, + }, + ); + + for attempt in 0..self.readiness.attempts { + match self.probe.probe(&config.endpoint).await { + Err(err) => { + self.stop_started_server(config.id, &handle).await; + return self.fail(config.id, err); + } + Ok(ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted) => { + self.publish( + config.id, + ModelServerLifecycleStatus::Ready { reused: false }, + ); + return Ok(EnsureLocalModelServerOutput { + ready: ready(&config, ModelServerStatus::ReadyStarted), + }); + } + Ok(ModelServerStatus::Unreachable) => { + if attempt + 1 < self.readiness.attempts && !self.readiness.backoff.is_zero() { + tokio::time::sleep(self.readiness.backoff).await; + } + } + } + } + + let err = ModelServerError::Timeout; + self.stop_started_server(config.id, &handle).await; + self.fail(config.id, err) + } + + /// Stops active servers whose policy is [`StopPolicy::StopOnAppExit`]. + /// + /// # Errors + /// Returns the first process error after attempting every eligible stop. + pub async fn stop_on_app_exit(&self) -> Result<(), AppError> { + let entries: Vec<(LocalModelServerId, ActiveServer)> = self + .active + .lock() + .unwrap() + .iter() + .map(|(id, active)| (*id, active.clone())) + .collect(); + let mut first_error: Option = None; + for (id, active) in entries { + if active.stop_policy != StopPolicy::StopOnAppExit { + continue; + } + if let Err(err) = self.process.kill(&active.handle).await { + if first_error.is_none() { + first_error = Some(err); + } + } else { + self.active.lock().unwrap().remove(&id); + } + } + if let Some(err) = first_error { + Err(err.into()) + } else { + Ok(()) + } + } + + async fn ensure_model_path_accessible( + &self, + config: &LocalModelServerConfig, + ) -> Result<(), AppError> { + let Some(path) = config.model.path.as_ref() else { + let err = ModelServerError::PathNotAccessible("model.path missing".to_owned()); + self.publish_failure(config.id, &err); + return Err(err.into()); + }; + match self + .fs + .exists(&RemotePath::new(path.as_str().to_owned())) + .await + { + Ok(true) => Ok(()), + Ok(false) => { + let err = ModelServerError::PathNotAccessible(path.as_str().to_owned()); + self.publish_failure(config.id, &err); + Err(err.into()) + } + Err(domain::ports::FsError::PermissionDenied(p)) => { + let err = ModelServerError::PermissionDenied(p); + self.publish_failure(config.id, &err); + Err(err.into()) + } + Err(err) => { + let err = ModelServerError::PathNotAccessible(err.to_string()); + self.publish_failure(config.id, &err); + Err(err.into()) + } + } + } + + async fn ensure_no_active_port_collision( + &self, + config: &LocalModelServerConfig, + ) -> Result<(), AppError> { + let active: Vec<(LocalModelServerId, ActiveServer)> = self + .active + .lock() + .unwrap() + .iter() + .map(|(id, active)| (*id, active.clone())) + .collect(); + for (id, active) in active { + if active.port != config.endpoint.port { + continue; + } + match self.process.status(&active.handle).await { + Ok(ProcessStatus::Running) => { + let err = ModelServerError::PortOccupied(config.endpoint.port); + self.publish_failure(config.id, &err); + return Err(err.into()); + } + Ok(ProcessStatus::Exited { .. } | ProcessStatus::Unknown) => { + self.active.lock().unwrap().remove(&id); + } + Err(err) => return self.fail(config.id, err), + } + } + Ok(()) + } + + async fn stop_started_server( + &self, + server_id: LocalModelServerId, + handle: &ManagedProcessHandle, + ) { + let _ = self.process.kill(handle).await; + self.active.lock().unwrap().remove(&server_id); + } + + fn publish(&self, server_id: LocalModelServerId, status: ModelServerLifecycleStatus) { + self.events + .publish(DomainEvent::ModelServerStatusChanged { server_id, status }); + } + + fn fail(&self, server_id: LocalModelServerId, err: ModelServerError) -> Result { + self.publish_failure(server_id, &err); + Err(err.into()) + } + + fn publish_failure(&self, server_id: LocalModelServerId, err: &ModelServerError) { + self.publish( + server_id, + ModelServerLifecycleStatus::Failed { + code: model_server_error_code(err).to_owned(), + message: err.to_string(), + }, + ); + } +} + +fn ready(config: &LocalModelServerConfig, status: ModelServerStatus) -> ModelServerReady { + ModelServerReady { + base_url: config.endpoint.base_url.clone(), + model: config.model.served_name.clone(), + status, + } +} + +/// Stable model-server error code for event/DTO mapping. +#[must_use] +pub fn model_server_error_code(err: &ModelServerError) -> &'static str { + match err { + ModelServerError::NotConfigured => "not_configured", + ModelServerError::Invalid(_) => "invalid", + ModelServerError::PermissionDenied(_) => "permission_denied", + ModelServerError::PathNotAccessible(_) => "path_not_accessible", + ModelServerError::PortOccupied(_) => "port_occupied", + ModelServerError::InUse(_) => "model_server_in_use", + ModelServerError::Probe(_) => "probe", + ModelServerError::Process(_) => "process", + ModelServerError::Store(_) => "store", + ModelServerError::Timeout => "timeout", + } +} diff --git a/crates/application/tests/model_server.rs b/crates/application/tests/model_server.rs new file mode 100644 index 0000000..3f5fe15 --- /dev/null +++ b/crates/application/tests/model_server.rs @@ -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>); + +#[async_trait] +impl ModelServerRegistry for FakeRegistry { + async fn get( + &self, + id: &LocalModelServerId, + ) -> Result, ModelServerError> { + Ok(self.0.lock().unwrap().get(id).cloned()) + } + + async fn list(&self) -> Result, 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>); + +#[async_trait] +impl ProfileStore for FakeProfiles { + async fn list(&self) -> Result, 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 { + 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>); + +impl FakeProbe { + fn new(statuses: Vec) -> Self { + Self(Mutex::new(statuses.into())) + } +} + +#[async_trait] +impl ModelServerProbe for FakeProbe { + async fn probe( + &self, + _endpoint: &ModelServerEndpoint, + ) -> Result { + Ok(self + .0 + .lock() + .unwrap() + .pop_front() + .unwrap_or(ModelServerStatus::Unreachable)) + } +} + +#[derive(Default)] +struct FakeProcess { + spawns: Mutex>, + kills: Mutex>, + statuses: Mutex>, +} + +#[async_trait] +impl ManagedProcess for FakeProcess { + async fn spawn(&self, spec: SpawnSpec) -> Result { + 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 { + 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 { + 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>, +} + +#[async_trait] +impl FileSystem for FakeFs { + async fn read(&self, _path: &RemotePath) -> Result, 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 { + 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, FsError> { + Ok(Vec::new()) + } + + async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { + Ok(()) + } +} + +#[derive(Default)] +struct FakeEvents(Mutex>); + +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, + probe: Arc, + process: Arc, + fs: Arc, + events: Arc, +) -> 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, + profiles, + ); + + usecase + .execute(DeleteModelServerInput { server_id: sid(9) }) + .await + .unwrap(); + + assert!(registry.get(&sid(9)).await.unwrap().is_none()); +} diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index d7d8bae..4c957dd 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -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>); + +impl SeqIds { + fn new(ids: Vec) -> 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 // --------------------------------------------------------------------------- diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 43b3fd5..36e61a9 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -3,7 +3,8 @@ use crate::conversation::ConversationParty; use crate::ids::{ - AgentId, IssueId, ProfileId, ProjectId, SessionId, SkillId, SprintId, TaskId, TemplateId, + AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId, + TaskId, TemplateId, }; use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion}; use crate::mailbox::TicketId; @@ -46,6 +47,24 @@ pub enum DomainEvent { /// The session it runs in. session_id: SessionId, }, + /// An agent launch failed before a runtime session could be created. + AgentLaunchFailed { + /// The agent whose launch failed. + agent_id: AgentId, + /// Failure cause namespace. + cause: String, + /// Stable error code. + code: String, + /// Human-readable message. + message: String, + }, + /// A local model-server status changed while preparing a launch. + ModelServerStatusChanged { + /// The local model server. + server_id: LocalModelServerId, + /// New lifecycle status. + status: crate::model_server::ModelServerLifecycleStatus, + }, /// A first-class background task started running. BackgroundTaskStarted { /// The owning project. diff --git a/crates/domain/src/ids.rs b/crates/domain/src/ids.rs index b65e945..238e79b 100644 --- a/crates/domain/src/ids.rs +++ b/crates/domain/src/ids.rs @@ -68,6 +68,10 @@ typed_id!( /// Identifies an [`crate::profile::AgentProfile`]. ProfileId ); +typed_id!( + /// Identifies a local model server configuration. + LocalModelServerId +); typed_id!( /// Identifies a [`crate::skill::Skill`]. SkillId diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 45d096e..b009457 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -49,6 +49,7 @@ pub mod mailbox; pub mod markdown; pub mod memory; pub mod memory_harvest; +pub mod model_server; pub mod orchestrator; pub mod permission; pub mod ports; @@ -72,8 +73,8 @@ mod validation; pub use error::DomainError; pub use ids::{ - AgentId, IssueId, LayoutId, NodeId, ProfileId, ProjectId, ScheduleId, SessionId, SkillId, - SprintId, TabId, TaskId, TemplateId, WindowId, + AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, ScheduleId, + SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId, }; pub use project::{Project, ProjectPath}; @@ -153,6 +154,12 @@ pub use memory_harvest::{ MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS, }; +pub use model_server::{ + ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath, + ModelServerEndpoint, ModelServerLifecycleStatus, ModelServerReady, ModelServerStatus, + StopPolicy, +}; + pub use remote::{RemoteKind, RemoteRef, SshAuth}; pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession}; diff --git a/crates/domain/src/model_server.rs b/crates/domain/src/model_server.rs new file mode 100644 index 0000000..a2816de --- /dev/null +++ b/crates/domain/src/model_server.rs @@ -0,0 +1,368 @@ +//! Local model-server configuration and status. +//! +//! These value objects describe a local OpenAI-compatible server that can back an +//! OpenCode profile. They are pure persisted configuration: probing, process +//! spawning and filesystem checks live behind ports. + +use std::net::Ipv4Addr; + +use serde::{Deserialize, Serialize}; + +use crate::error::DomainError; +use crate::ids::LocalModelServerId; + +/// Local model server implementation family. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LocalModelServerKind { + /// `llama-server` from llama.cpp. + LlamaCpp, +} + +/// HTTP endpoint for an OpenAI-compatible model server. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelServerEndpoint { + /// Normalised base URL, always ending in `/v1`. + #[serde(rename = "baseURL")] + pub base_url: String, + /// TCP port expected for the local server. + pub port: u16, +} + +impl ModelServerEndpoint { + /// Builds a validated endpoint and normalises its base URL to `/v1`. + /// + /// # Errors + /// Returns [`DomainError`] if the URL is blank, non-HTTP(S), or the port is 0. + pub fn new(base_url: impl Into, port: u16) -> Result { + let base_url = normalise_base_url(base_url.into())?; + if port == 0 { + return Err(DomainError::Invariant( + "modelServer.endpoint.port must be in 1..=65535".to_owned(), + )); + } + Ok(Self { base_url, port }) + } +} + +fn normalise_base_url(raw: String) -> Result { + let trimmed = raw.trim().trim_end_matches('/').to_owned(); + crate::validation::non_empty(&trimmed, "modelServer.endpoint.baseURL")?; + if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) { + return Err(DomainError::Invariant( + "modelServer.endpoint.baseURL must start with http:// or https://".to_owned(), + )); + } + if !has_local_host_authority(&trimmed) { + return Err(DomainError::Invariant( + "modelServer.endpoint.baseURL must target localhost in V1".to_owned(), + )); + } + if trimmed.ends_with("/v1") { + Ok(trimmed) + } else { + Ok(format!("{trimmed}/v1")) + } +} + +fn has_local_host_authority(url: &str) -> bool { + let Some(authority_and_path) = url.split_once("://").map(|(_, rest)| rest) else { + return false; + }; + let authority = authority_and_path.split('/').next().unwrap_or_default(); + let host = authority + .rsplit_once('@') + .map(|(_, host)| host) + .unwrap_or(authority); + let host = host + .strip_prefix('[') + .and_then(|ipv6| ipv6.split_once(']').map(|(addr, _)| addr)) + .unwrap_or_else(|| host.split(':').next().unwrap_or_default()); + host.eq_ignore_ascii_case("localhost") || host == "::1" || is_ipv4_loopback(host) +} + +fn is_ipv4_loopback(host: &str) -> bool { + host.parse::() + .map(|addr| addr.octets()[0] == 127) + .unwrap_or(false) +} + +/// Absolute local path to a model file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ModelPath(pub String); + +impl ModelPath { + /// Builds a validated absolute model path. + /// + /// # Errors + /// Returns [`DomainError`] if the path is blank or relative. + pub fn new(path: impl Into) -> Result { + let path = path.into(); + crate::validation::non_empty(&path, "modelServer.model.path")?; + if !is_absolute_path(&path) { + return Err(DomainError::Invariant( + "modelServer.model.path must be absolute".to_owned(), + )); + } + Ok(Self(path)) + } + + /// Returns the path as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Absolute local path to an executable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ExecutablePath(pub String); + +impl ExecutablePath { + /// Builds a validated executable path. + /// + /// Absolute paths are accepted directly. Bare command names are accepted so + /// infrastructure can resolve them through `PATH` without shell interpolation. + /// + /// # Errors + /// Returns [`DomainError`] if the path is blank. + pub fn new(path: impl Into) -> Result { + let path = path.into(); + crate::validation::non_empty(&path, "modelServer.binary")?; + Ok(Self(path)) + } + + /// Returns the path as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +fn is_absolute_path(path: &str) -> bool { + path.starts_with('/') + || path.starts_with('\\') + || (path.len() >= 3 + && path.as_bytes()[1] == b':' + && path.as_bytes()[0].is_ascii_alphabetic() + && matches!(path.as_bytes()[2], b'/' | b'\\')) +} + +/// Model served by a local model server. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalModelRef { + /// Stable model id in IdeA configuration. + pub id: String, + /// Human display label. + pub label: String, + /// Explicit absolute `.gguf` path. Required for auto-start. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Name exposed by the server and used in `OpenCodeConfig.model`. + pub served_name: String, +} + +impl LocalModelRef { + /// Builds a validated local model reference. + /// + /// # Errors + /// Returns [`DomainError`] if `id`, `label` or `served_name` is blank. + pub fn new( + id: impl Into, + label: impl Into, + path: Option, + served_name: impl Into, + ) -> Result { + let id = id.into(); + let label = label.into(); + let served_name = served_name.into(); + crate::validation::non_empty(&id, "modelServer.model.id")?; + crate::validation::non_empty(&label, "modelServer.model.label")?; + crate::validation::non_empty(&served_name, "modelServer.model.servedName")?; + Ok(Self { + id, + label, + path, + served_name, + }) + } +} + +/// Stop policy for a managed local model server. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StopPolicy { + /// Leave the process running. + KeepAlive, + /// Stop when no profile uses it. Not used aggressively in V1. + StopWhenUnused, + /// Stop on application shutdown. + StopOnAppExit, +} + +/// Persisted configuration for a local model server. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalModelServerConfig { + /// Stable identity. + pub id: LocalModelServerId, + /// Server implementation family. + pub kind: LocalModelServerKind, + /// Display name. + pub name: String, + /// OpenAI-compatible endpoint. + pub endpoint: ModelServerEndpoint, + /// Served model. + pub model: LocalModelRef, + /// Optional executable path or bare command name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binary: Option, + /// Extra argv passed to the runtime. + #[serde(default)] + pub args: Vec, + /// Whether IdeA may start the server lazily. + pub auto_start: bool, + /// Process stop policy. + #[serde(default = "default_stop_policy")] + pub stop_policy: StopPolicy, +} + +fn default_stop_policy() -> StopPolicy { + StopPolicy::StopOnAppExit +} + +impl LocalModelServerConfig { + /// Builds a validated local server configuration. + /// + /// # Errors + /// Returns [`DomainError`] if names are blank or auto-start invariants are not + /// satisfied by static data. + #[allow(clippy::too_many_arguments)] + pub fn new( + id: LocalModelServerId, + kind: LocalModelServerKind, + name: impl Into, + endpoint: ModelServerEndpoint, + model: LocalModelRef, + binary: Option, + args: Vec, + auto_start: bool, + stop_policy: StopPolicy, + ) -> Result { + let name = name.into(); + crate::validation::non_empty(&name, "modelServer.name")?; + if auto_start { + if kind != LocalModelServerKind::LlamaCpp { + return Err(DomainError::Invariant( + "modelServer.autoStart requires kind=LlamaCpp".to_owned(), + )); + } + if model.path.is_none() { + return Err(DomainError::Invariant( + "modelServer.autoStart requires model.path".to_owned(), + )); + } + } + Ok(Self { + id, + kind, + name, + endpoint, + model, + binary, + args, + auto_start, + stop_policy, + }) + } +} + +/// Observed model-server status. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModelServerStatus { + /// The server is reachable and was already running. + ReadyReused, + /// The server is reachable after IdeA started it. + ReadyStarted, + /// The server is not reachable. + Unreachable, +} + +/// Successful ensure result used by agent launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelServerReady { + /// Base URL to use in OpenCode config. + pub base_url: String, + /// Served model name to use in OpenCode config. + pub model: String, + /// Whether the server was reused or started. + pub status: ModelServerStatus, +} + +/// Lifecycle status emitted for presentation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModelServerLifecycleStatus { + /// No local model server is configured for the profile. + NotConfigured, + /// A readiness probe is in progress. + Probing, + /// IdeA is starting the server. + Starting, + /// The server is ready. + Ready { + /// `true` when an already-running server was reused. + reused: bool, + }, + /// The server failed to become ready. + Failed { + /// Stable error code. + code: String, + /// Human-readable message. + message: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_normalises_to_v1() { + let endpoint = ModelServerEndpoint::new("http://localhost:8080", 8080).unwrap(); + assert_eq!(endpoint.base_url, "http://localhost:8080/v1"); + let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1/", 8080).unwrap(); + assert_eq!(endpoint.base_url, "http://localhost:8080/v1"); + } + + #[test] + fn endpoint_rejects_remote_host() { + let err = ModelServerEndpoint::new("http://example.com:8080", 8080).unwrap_err(); + assert!(err.to_string().contains("localhost")); + let err = + ModelServerEndpoint::new("http://127.0.0.1.evil.com:8080", 8080).unwrap_err(); + assert!(err.to_string().contains("localhost")); + } + + #[test] + fn auto_start_requires_model_path() { + let endpoint = ModelServerEndpoint::new("http://localhost:8080/v1", 8080).unwrap(); + let model = LocalModelRef::new("qwen", "Qwen", None, "qwen").unwrap(); + let err = LocalModelServerConfig::new( + LocalModelServerId::from_uuid(uuid::Uuid::nil()), + LocalModelServerKind::LlamaCpp, + "llama.cpp", + endpoint, + model, + None, + Vec::new(), + true, + StopPolicy::StopOnAppExit, + ) + .unwrap_err(); + assert!(err.to_string().contains("model.path")); + } +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 9de29a6..b0f3805 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -34,12 +34,15 @@ use crate::background_task::{ BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy, }; use crate::events::DomainEvent; -use crate::ids::{AgentId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId}; +use crate::ids::{ + AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId, +}; use crate::issue::{ Issue, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssueRef, IssueVersion, }; use crate::markdown::MarkdownDoc; use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug}; +use crate::model_server::{LocalModelServerConfig, ModelServerEndpoint, ModelServerStatus}; use crate::permission::ProjectPermissions; use crate::profile::{AgentProfile, EmbedderProfile}; use crate::project::{Project, ProjectPath}; @@ -206,6 +209,27 @@ pub struct Output { pub stderr: Vec, } +/// Opaque handle for a managed local process. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ManagedProcessHandle { + /// Stable local process identity as assigned by the adapter. + pub id: String, +} + +/// Observed status of a managed local process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProcessStatus { + /// Process is still running. + Running, + /// Process has exited. + Exited { + /// Exit code, when available. + code: Option, + }, + /// Handle is unknown to the adapter. + Unknown, +} + /// A location-neutral path used by [`FileSystem`] (local, SFTP, or WSL). #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RemotePath(pub String); @@ -502,6 +526,41 @@ pub enum ProcessError { Io(String), } +/// Errors from local model-server ports. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ModelServerError { + /// The referenced configuration does not exist. + #[error("not configured")] + NotConfigured, + /// Server or configuration is invalid. + #[error("invalid: {0}")] + Invalid(String), + /// Filesystem permission denied. + #[error("permission denied: {0}")] + PermissionDenied(String), + /// Required path is not accessible. + #[error("path not accessible: {0}")] + PathNotAccessible(String), + /// The configured port is already in use by another managed server. + #[error("port occupied: {0}")] + PortOccupied(u16), + /// The server is still referenced by another configuration object. + #[error("model server in use: {0}")] + InUse(String), + /// Readiness probe failed unexpectedly. + #[error("probe failed: {0}")] + Probe(String), + /// Process operation failed. + #[error("process failed: {0}")] + Process(String), + /// Store operation failed. + #[error("store failed: {0}")] + Store(String), + /// Readiness timed out. + #[error("readiness timed out")] + Timeout, +} + /// Errors from [`FileSystem`]. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum FsError { @@ -914,6 +973,88 @@ pub trait ProcessSpawner: Send + Sync { async fn run(&self, spec: SpawnSpec) -> Result; } +/// Probe readiness of an OpenAI-compatible model server. +#[async_trait] +pub trait ModelServerProbe: Send + Sync { + /// Probes `endpoint` and returns the observed status. + /// + /// # Errors + /// [`ModelServerError`] on unexpected probe failure. An unreachable server + /// should normally return [`ModelServerStatus::Unreachable`]. + async fn probe( + &self, + endpoint: &ModelServerEndpoint, + ) -> Result; +} + +/// Manages local long-lived child processes. +#[async_trait] +pub trait ManagedProcess: Send + Sync { + /// Spawns a process from an argv-structured spec. + /// + /// # Errors + /// [`ModelServerError`] on spawn failure. + async fn spawn(&self, spec: SpawnSpec) -> Result; + + /// Kills a process. + /// + /// # Errors + /// [`ModelServerError`] on kill failure. + async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError>; + + /// Returns the status of a process. + /// + /// # Errors + /// [`ModelServerError`] on status failure. + async fn status( + &self, + handle: &ManagedProcessHandle, + ) -> Result; +} + +/// Builds the argv-structured spawn spec for a local model server. +pub trait ModelServerRuntime: Send + Sync { + /// Builds a spawn spec from a validated local-server config. + /// + /// # Errors + /// [`ModelServerError`] if the config cannot be launched. + fn build_spawn_spec( + &self, + config: &LocalModelServerConfig, + ) -> Result; +} + +/// Persists local model-server configuration in the global IdeA store. +#[async_trait] +pub trait ModelServerRegistry: Send + Sync { + /// Returns one server config by id. + /// + /// # Errors + /// [`ModelServerError`] on store failure. + async fn get( + &self, + id: &LocalModelServerId, + ) -> Result, ModelServerError>; + + /// Lists all server configs. + /// + /// # Errors + /// [`ModelServerError`] on store failure. + async fn list(&self) -> Result, ModelServerError>; + + /// Saves one server config. + /// + /// # Errors + /// [`ModelServerError`] on store failure. + async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError>; + + /// Deletes one server config. + /// + /// # Errors + /// [`ModelServerError`] on store failure. + async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError>; +} + /// Location-neutral filesystem access. #[async_trait] pub trait FileSystem: Send + Sync { diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index 2c0a613..e550de8 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::error::DomainError; -use crate::ids::ProfileId; +use crate::ids::{LocalModelServerId, ProfileId}; use crate::permission::ProjectorKey; /// Strategy for injecting an agent's `.md` context into the launched CLI. @@ -295,6 +295,11 @@ pub struct OpenCodeConfig { /// Active les attachments côté modèle OpenCode. Défaut effectif : `false`. #[serde(default, skip_serializing_if = "Option::is_none")] pub attachment: Option, + /// Serveur local managé associé, quand ce profil dépend d'un lifecycle IdeA. + /// + /// `None` garde le comportement existant : profil OpenCode manuel/externe. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local_model_server_id: Option, } impl OpenCodeConfig { @@ -325,9 +330,17 @@ impl OpenCodeConfig { model, reasoning, attachment, + local_model_server_id: None, }) } + /// Builder : associe cette configuration à un serveur local managé. + #[must_use] + pub const fn with_local_model_server_id(mut self, id: LocalModelServerId) -> Self { + self.local_model_server_id = Some(id); + self + } + /// Valeur effective de `reasoning` quand le profil ne la porte pas. #[must_use] pub fn reasoning_enabled(&self) -> bool { @@ -1107,7 +1120,7 @@ impl AgentProfile { #[cfg(test)] mod mcp_tests { use super::*; - use crate::ids::ProfileId; + use crate::ids::{LocalModelServerId, ProfileId}; /// A reference profile without any MCP capability (the historical shape). fn profile_without_mcp() -> AgentProfile { @@ -1225,6 +1238,46 @@ mod mcp_tests { assert!(OpenCodeConfig::new("http://localhost:8080/v1", None, " ", None, None).is_err()); } + #[test] + fn opencode_config_omits_local_model_server_id_when_absent() { + let config = OpenCodeConfig::new( + "http://localhost:8080/v1", + None, + "qwen3-coder-30b", + None, + None, + ) + .unwrap(); + + let json = serde_json::to_string(&config).expect("serialise"); + assert!( + !json.contains("localModelServerId"), + "manual external OpenCode profiles must keep the legacy JSON shape: {json}" + ); + let back: OpenCodeConfig = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(back.local_model_server_id, None); + } + + #[test] + fn opencode_config_serialises_local_model_server_id_camelcase() { + let server_id = LocalModelServerId::from_uuid(uuid::Uuid::from_u128(35)); + let config = OpenCodeConfig::new( + "http://localhost:8080/v1", + None, + "qwen3-coder-30b", + None, + None, + ) + .unwrap() + .with_local_model_server_id(server_id); + + let value = serde_json::to_value(&config).expect("serialise"); + assert_eq!(value["localModelServerId"], server_id.to_string()); + assert!(value.get("local_model_server_id").is_none()); + let back: OpenCodeConfig = serde_json::from_value(value).expect("deserialise"); + assert_eq!(back.local_model_server_id, Some(server_id)); + } + #[test] fn openai_compatible_stays_non_mcp_native_even_with_mcp_declared() { let profile = profile_without_mcp() diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 1da72b2..3a4e1ba 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -26,6 +26,7 @@ pub mod input; pub mod inspector; pub mod issues; pub mod mailbox; +pub mod model_server; pub mod orchestrator; pub mod permission; pub mod process; @@ -64,6 +65,9 @@ pub use inspector::{ }; pub use issues::{FsIssueNumberAllocator, FsIssueStore}; pub use mailbox::InMemoryMailbox; +pub use model_server::{ + FsModelServerRegistry, HttpOpenAiCompatibleProbe, LlamaCppRuntime, LocalManagedProcess, +}; pub use orchestrator::mcp::{ McpServer, MemoryTransport, StdioTransport, TicketToolError, TicketToolProvider, ToolPolicyRegistry, diff --git a/crates/infrastructure/src/model_server/mod.rs b/crates/infrastructure/src/model_server/mod.rs new file mode 100644 index 0000000..df0014e --- /dev/null +++ b/crates/infrastructure/src/model_server/mod.rs @@ -0,0 +1,315 @@ +//! Infrastructure adapters for local model servers. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::Duration; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tokio::process::{Child, Command}; + +use domain::model_server::{LocalModelServerConfig, LocalModelServerKind, ModelServerEndpoint}; +use domain::ports::{ + FileSystem, ManagedProcess, ManagedProcessHandle, ModelServerError, ModelServerProbe, + ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath, SpawnSpec, +}; +use domain::{LocalModelServerId, ProjectPath}; + +/// HTTP readiness probe for OpenAI-compatible servers. +#[derive(Clone)] +pub struct HttpOpenAiCompatibleProbe { + client: reqwest::Client, +} + +impl Default for HttpOpenAiCompatibleProbe { + fn default() -> Self { + Self::new(Duration::from_secs(2)) + } +} + +impl HttpOpenAiCompatibleProbe { + /// Builds the probe with a request timeout. + #[must_use] + pub fn new(timeout: Duration) -> Self { + let client = reqwest::Client::builder() + .timeout(timeout) + .build() + .expect("reqwest client with timeout builds"); + Self { client } + } +} + +#[async_trait] +impl ModelServerProbe for HttpOpenAiCompatibleProbe { + async fn probe( + &self, + endpoint: &ModelServerEndpoint, + ) -> Result { + let models = format!("{}/models", endpoint.base_url.trim_end_matches('/')); + if is_ready(self.client.get(models).send().await) { + return Ok(domain::ModelServerStatus::ReadyReused); + } + let root = endpoint.base_url.trim_end_matches("/v1").to_owned(); + if is_ready(self.client.get(root).send().await) { + return Ok(domain::ModelServerStatus::ReadyReused); + } + Ok(domain::ModelServerStatus::Unreachable) + } +} + +fn is_ready(result: Result) -> bool { + result + .map(|response| response.status().is_success()) + .unwrap_or(false) +} + +/// Builds `llama-server` argv without shell interpolation. +#[derive(Debug, Default, Clone, Copy)] +pub struct LlamaCppRuntime; + +impl LlamaCppRuntime { + /// Creates the runtime. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl ModelServerRuntime for LlamaCppRuntime { + fn build_spawn_spec( + &self, + config: &LocalModelServerConfig, + ) -> Result { + if config.kind != LocalModelServerKind::LlamaCpp { + return Err(ModelServerError::Invalid( + "only LlamaCpp local model servers are supported".to_owned(), + )); + } + let model_path = + config.model.path.as_ref().ok_or_else(|| { + ModelServerError::PathNotAccessible("model.path missing".to_owned()) + })?; + let command = resolve_binary( + config + .binary + .as_ref() + .map(|binary| binary.as_str()) + .unwrap_or("llama-server"), + )?; + let mut args = vec![ + "--model".to_owned(), + model_path.as_str().to_owned(), + "--port".to_owned(), + config.endpoint.port.to_string(), + ]; + args.extend(config.args.clone()); + Ok(SpawnSpec { + command, + args, + cwd: ProjectPath::new("/").map_err(|e| ModelServerError::Invalid(e.to_string()))?, + env: Vec::new(), + context_plan: None, + sandbox: None, + }) + } +} + +fn resolve_binary(raw: &str) -> Result { + if is_path_like(raw) { + let path = Path::new(raw); + if path.is_file() { + return Ok(raw.to_owned()); + } + return Err(ModelServerError::PathNotAccessible(raw.to_owned())); + } + if let Some(path) = find_in_path(raw) { + return Ok(path.to_string_lossy().into_owned()); + } + Err(ModelServerError::PathNotAccessible(format!( + "{raw} not found in PATH" + ))) +} + +fn is_path_like(raw: &str) -> bool { + raw.contains('/') || raw.contains('\\') || Path::new(raw).is_absolute() +} + +fn find_in_path(command: &str) -> Option { + std::env::var_os("PATH").and_then(|path| { + std::env::split_paths(&path) + .map(|dir| dir.join(command)) + .find(|candidate| candidate.is_file()) + }) +} + +/// Local child-process manager for long-lived model servers. +#[derive(Default)] +pub struct LocalManagedProcess { + children: Mutex>, +} + +impl LocalManagedProcess { + /// Creates an empty process manager. + #[must_use] + pub fn new() -> Self { + Self::default() + } +} + +#[async_trait] +impl ManagedProcess for LocalManagedProcess { + async fn spawn(&self, spec: SpawnSpec) -> Result { + let mut command = Command::new(&spec.command); + command.args(&spec.args); + if spec.cwd.as_str() != "/" { + command.current_dir(spec.cwd.as_str()); + } + for (key, value) in &spec.env { + command.env(key, value); + } + let child = command + .spawn() + .map_err(|e| ModelServerError::Process(format!("{}: {e}", spec.command)))?; + let id = uuid::Uuid::new_v4().to_string(); + self.children.lock().unwrap().insert(id.clone(), child); + Ok(ManagedProcessHandle { id }) + } + + async fn kill(&self, handle: &ManagedProcessHandle) -> Result<(), ModelServerError> { + let Some(mut child) = self.children.lock().unwrap().remove(&handle.id) else { + return Ok(()); + }; + child + .start_kill() + .map_err(|e| ModelServerError::Process(e.to_string())) + } + + async fn status( + &self, + handle: &ManagedProcessHandle, + ) -> Result { + let mut children = self.children.lock().unwrap(); + let Some(child) = children.get_mut(&handle.id) else { + return Ok(ProcessStatus::Unknown); + }; + match child + .try_wait() + .map_err(|e| ModelServerError::Process(e.to_string()))? + { + Some(status) => Ok(ProcessStatus::Exited { + code: status.code(), + }), + None => Ok(ProcessStatus::Running), + } + } +} + +/// File name of the global local-model-server registry. +const MODEL_SERVERS_FILE: &str = "model-servers.json"; +const MODEL_SERVERS_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ModelServersDoc { + version: u32, + servers: Vec, +} + +impl Default for ModelServersDoc { + fn default() -> Self { + Self { + version: MODEL_SERVERS_VERSION, + servers: Vec::new(), + } + } +} + +/// Filesystem-backed global model-server registry. +#[derive(Clone)] +pub struct FsModelServerRegistry { + fs: std::sync::Arc, + app_data_dir: String, +} + +impl FsModelServerRegistry { + /// Builds the registry from a filesystem and global app-data dir. + #[must_use] + pub fn new(fs: std::sync::Arc, app_data_dir: impl Into) -> Self { + Self { + fs, + app_data_dir: app_data_dir.into(), + } + } + + fn path(&self) -> RemotePath { + let base = self.app_data_dir.trim_end_matches(['/', '\\']); + RemotePath::new(format!("{base}/{MODEL_SERVERS_FILE}")) + } + + async fn read_doc(&self) -> Result { + match self.fs.read(&self.path()).await { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|e| ModelServerError::Store(format!("serialization failed: {e}"))), + Err(domain::ports::FsError::NotFound(_)) => Ok(ModelServersDoc::default()), + Err(domain::ports::FsError::PermissionDenied(p)) => { + Err(ModelServerError::PermissionDenied(p)) + } + Err(e) => Err(ModelServerError::Store(e.to_string())), + } + } + + async fn write_doc(&self, doc: &ModelServersDoc) -> Result<(), ModelServerError> { + let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned()); + self.fs.create_dir_all(&dir).await.map_err(|e| match e { + domain::ports::FsError::PermissionDenied(p) => ModelServerError::PermissionDenied(p), + other => ModelServerError::Store(other.to_string()), + })?; + let bytes = serde_json::to_vec_pretty(doc) + .map_err(|e| ModelServerError::Store(format!("serialization failed: {e}")))?; + self.fs + .write(&self.path(), &bytes) + .await + .map_err(|e| match e { + domain::ports::FsError::PermissionDenied(p) => { + ModelServerError::PermissionDenied(p) + } + other => ModelServerError::Store(other.to_string()), + }) + } +} + +#[async_trait] +impl ModelServerRegistry for FsModelServerRegistry { + async fn get( + &self, + id: &LocalModelServerId, + ) -> Result, ModelServerError> { + Ok(self + .read_doc() + .await? + .servers + .into_iter() + .find(|server| &server.id == id)) + } + + async fn list(&self) -> Result, ModelServerError> { + Ok(self.read_doc().await?.servers) + } + + async fn save(&self, config: LocalModelServerConfig) -> Result<(), ModelServerError> { + let mut doc = self.read_doc().await?; + if let Some(slot) = doc.servers.iter_mut().find(|server| server.id == config.id) { + *slot = config; + } else { + doc.servers.push(config); + } + self.write_doc(&doc).await + } + + async fn delete(&self, id: LocalModelServerId) -> Result<(), ModelServerError> { + let mut doc = self.read_doc().await?; + doc.servers.retain(|server| server.id != id); + self.write_doc(&doc).await + } +} diff --git a/crates/infrastructure/tests/model_server.rs b/crates/infrastructure/tests/model_server.rs new file mode 100644 index 0000000..997959c --- /dev/null +++ b/crates/infrastructure/tests/model_server.rs @@ -0,0 +1,111 @@ +//! Infrastructure tests for local model-server adapters. + +use std::path::PathBuf; +use std::sync::Arc; + +use domain::model_server::{ + ExecutablePath, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath, + ModelServerEndpoint, StopPolicy, +}; +use domain::ports::{FileSystem, ModelServerRegistry, ModelServerRuntime, RemotePath}; +use domain::LocalModelServerId; +use infrastructure::{FsModelServerRegistry, LlamaCppRuntime, LocalFileSystem}; +use uuid::Uuid; + +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("idea-model-server-{}", Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn app_data_dir(&self) -> String { + self.0.to_string_lossy().into_owned() + } + + fn child(&self, name: &str) -> RemotePath { + RemotePath::new(self.0.join(name).to_string_lossy().into_owned()) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn sid(n: u128) -> LocalModelServerId { + LocalModelServerId::from_uuid(Uuid::from_u128(n)) +} + +fn config(id: LocalModelServerId, port: u16) -> LocalModelServerConfig { + let binary = std::env::current_exe() + .unwrap() + .to_string_lossy() + .into_owned(); + LocalModelServerConfig::new( + id, + LocalModelServerKind::LlamaCpp, + "Local Qwen", + ModelServerEndpoint::new(format!("http://localhost:{port}/v1"), port).unwrap(), + LocalModelRef::new( + "qwen", + "Qwen", + Some(ModelPath::new("/models/qwen.gguf").unwrap()), + "qwen3-coder-30b", + ) + .unwrap(), + Some(ExecutablePath::new(binary).unwrap()), + vec!["--ctx-size".to_owned(), "8192".to_owned()], + true, + StopPolicy::StopOnAppExit, + ) + .unwrap() +} + +#[test] +fn llamacpp_runtime_builds_structured_argv() { + let spec = LlamaCppRuntime::new() + .build_spawn_spec(&config(sid(1), 8080)) + .unwrap(); + + assert_eq!( + spec.command, + std::env::current_exe().unwrap().to_string_lossy() + ); + assert_eq!( + spec.args, + vec![ + "--model", + "/models/qwen.gguf", + "--port", + "8080", + "--ctx-size", + "8192" + ] + ); + assert!(spec.context_plan.is_none()); +} + +#[tokio::test] +async fn fs_model_server_registry_roundtrips_global_json() { + let tmp = TempDir::new(); + let fs: Arc = Arc::new(LocalFileSystem::new()); + let registry = FsModelServerRegistry::new(Arc::clone(&fs), tmp.app_data_dir()); + + let first = config(sid(2), 8081); + registry.save(first.clone()).await.unwrap(); + + assert_eq!(registry.get(&first.id).await.unwrap(), Some(first.clone())); + assert_eq!(registry.list().await.unwrap(), vec![first.clone()]); + assert!( + fs.exists(&tmp.child("model-servers.json")).await.unwrap(), + "registry must live in global app-data dir" + ); + + let updated = config(sid(2), 8082); + registry.save(updated.clone()).await.unwrap(); + assert_eq!(registry.list().await.unwrap(), vec![updated]); +} From 72476a650ac045ccb432c4e8d3f285e2bcfb5213 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 11 Jul 2026 15:50:18 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(model-server):=20frontend=20mod=C3=A8l?= =?UTF-8?q?es=20locaux=20=E2=80=94=20badge=20de=20statut=20&=20CRUD=20serv?= =?UTF-8?q?eurs=20(#35)=20et=20wizard=20multi-profils=20OpenCode=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint « Modeles locaux », couche frontend. #35 : - F35.1 badge de statut de lancement du serveur local (ModelServerLaunchBadge + useAgentsModelServer). - F35.2 feature model-servers : CRUD (ModelServersPanel / useModelServers / gateway modelServer) et ModelServerSelect. #36 : - Liste multi-profils OpenCode dans le wizard de premier lancement, gateway de clonage (clone_opencode_profile_from_seed). Tests verts (exécution réelle) : tsc propre, vitest 608/608. Co-Authored-By: Claude Opus 4.8 --- frontend/src/adapters/index.ts | 3 + frontend/src/adapters/mock/index.ts | 61 ++++ frontend/src/adapters/mock/mock.test.ts | 1 + frontend/src/adapters/mock/profile.test.ts | 29 ++ frontend/src/adapters/modelServer.ts | 31 ++ frontend/src/adapters/profile.ts | 10 +- frontend/src/domain/index.ts | 88 +++++ frontend/src/features/agents/AgentsPanel.tsx | 16 + .../agents/ModelServerLaunchBadge.test.tsx | 100 ++++++ .../agents/ModelServerLaunchBadge.tsx | 115 +++++++ frontend/src/features/agents/index.ts | 4 +- frontend/src/features/agents/useAgents.ts | 60 ++++ .../agents/useAgentsModelServer.test.tsx | 129 +++++++ .../first-run/FirstRunWizard.test.tsx | 184 +++++++++- .../src/features/first-run/FirstRunWizard.tsx | 133 +++++++- .../src/features/first-run/useFirstRun.ts | 29 ++ .../model-servers/ModelServerSelect.tsx | 57 ++++ .../model-servers/ModelServersPanel.tsx | 323 ++++++++++++++++++ frontend/src/features/model-servers/index.ts | 19 ++ .../src/features/model-servers/modelServer.ts | 97 ++++++ .../model-servers/modelServers.test.tsx | 251 ++++++++++++++ .../features/model-servers/useModelServers.ts | 126 +++++++ frontend/src/ports/index.ts | 42 +++ 23 files changed, 1893 insertions(+), 15 deletions(-) create mode 100644 frontend/src/adapters/modelServer.ts create mode 100644 frontend/src/features/agents/ModelServerLaunchBadge.test.tsx create mode 100644 frontend/src/features/agents/ModelServerLaunchBadge.tsx create mode 100644 frontend/src/features/agents/useAgentsModelServer.test.tsx create mode 100644 frontend/src/features/model-servers/ModelServerSelect.tsx create mode 100644 frontend/src/features/model-servers/ModelServersPanel.tsx create mode 100644 frontend/src/features/model-servers/index.ts create mode 100644 frontend/src/features/model-servers/modelServer.ts create mode 100644 frontend/src/features/model-servers/modelServers.test.tsx create mode 100644 frontend/src/features/model-servers/useModelServers.ts diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 4fa7745..240e05a 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -19,6 +19,7 @@ import { TauriProjectGateway } from "./project"; import { TauriTerminalGateway } from "./terminal"; import { TauriLayoutGateway } from "./layout"; import { TauriProfileGateway } from "./profile"; +import { TauriModelServerGateway } from "./modelServer"; import { TauriTemplateGateway } from "./template"; import { TauriSkillGateway } from "./skill"; import { TauriMemoryGateway } from "./memory"; @@ -56,6 +57,7 @@ export function createTauriGateways(): Gateways { git: new TauriGitGateway(), remote: new TauriRemoteGateway(), profile: new TauriProfileGateway(), + modelServer: new TauriModelServerGateway(), template: new TauriTemplateGateway(), skill: new TauriSkillGateway(), memory: new TauriMemoryGateway(), @@ -76,6 +78,7 @@ export { TauriTerminalGateway, TauriLayoutGateway, TauriProfileGateway, + TauriModelServerGateway, TauriTemplateGateway, TauriSkillGateway, TauriMemoryGateway, diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 68c53f3..9ff1d29 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -23,6 +23,7 @@ import type { LayoutList, LayoutOperation, LayoutTree, + LocalModelServerConfig, Memory, MemoryIndexEntry, MemoryLink, @@ -57,6 +58,7 @@ import type { ConversationGateway, ConversationPageRequest, ConversationDetails, + CloneOpenCodeProfileFromSeedInput, CreateAgentInput, CreateMemoryInput, CreateSkillInput, @@ -68,6 +70,7 @@ import type { MemoryGateway, GitGateway, LayoutGateway, + ModelServerGateway, OpenTerminalOptions, ProfileGateway, ProjectGateway, @@ -1185,6 +1188,8 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [ export class MockProfileGateway implements ProfileGateway { private profiles: AgentProfile[] = []; private configured = false; + /** Monotonic suffix so cloned OpenCode profiles get distinct ids/names. */ + private cloneCounter = 0; async firstRunState(): Promise { return { @@ -1228,6 +1233,61 @@ export class MockProfileGateway implements ProfileGateway { this.configured = true; return structuredClone(profiles); } + + async cloneOpenCodeProfileFromSeed( + input: CloneOpenCodeProfileFromSeedInput = {}, + ): Promise { + const seed = MOCK_REFERENCE_PROFILES.find( + (p) => p.structuredAdapter === "openCode", + ); + if (!seed) throw new Error("no OpenCode seed profile"); + this.cloneCounter += 1; + return structuredClone({ + ...seed, + id: `mock-opencode-clone-${this.cloneCounter}`, + name: input.name ?? `${seed.name} (copy ${this.cloneCounter})`, + opencode: input.opencode ?? seed.opencode, + }); + } +} + +/** + * In-memory local model server registry (F35). Tracks declared servers and can + * be told which ids are still referenced by a profile, so `deleteModelServer` + * reproduces the backend `model_server_in_use` rejection offline. + */ +export class MockModelServerGateway implements ModelServerGateway { + private servers: LocalModelServerConfig[] = []; + private readonly inUse = new Set(); + + async listModelServers(): Promise { + return structuredClone(this.servers); + } + + async saveModelServer( + config: LocalModelServerConfig, + ): Promise { + const i = this.servers.findIndex((s) => s.id === config.id); + if (i >= 0) this.servers[i] = structuredClone(config); + else this.servers.push(structuredClone(config)); + return structuredClone(config); + } + + async deleteModelServer(serverId: string): Promise { + if (this.inUse.has(serverId)) { + const err: GatewayError = { + code: "model_server_in_use", + message: "A profile still references this server.", + }; + throw err; + } + this.servers = this.servers.filter((s) => s.id !== serverId); + } + + /** Test hook: mark a server id as still referenced (delete then rejects). */ + markInUse(serverId: string): void { + this.inUse.add(serverId); + } } /** @@ -2454,6 +2514,7 @@ export function createMockGateways(): Gateways { git: new MockGitGateway(), remote: new MockRemoteGateway(), profile: new MockProfileGateway(), + modelServer: new MockModelServerGateway(), template: new MockTemplateGateway(agentGateway), skill: new MockSkillGateway(agentGateway), memory: new MockMemoryGateway(), diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 7566d56..162ec31 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -21,6 +21,7 @@ describe("createMockGateways", () => { "input", "layout", "memory", + "modelServer", "permission", "profile", "project", diff --git a/frontend/src/adapters/mock/profile.test.ts b/frontend/src/adapters/mock/profile.test.ts index b9a6f15..74d4bbe 100644 --- a/frontend/src/adapters/mock/profile.test.ts +++ b/frontend/src/adapters/mock/profile.test.ts @@ -92,4 +92,33 @@ describe("MockProfileGateway", () => { await gw.configureProfiles([]); expect((await gw.firstRunState()).isFirstRun).toBe(false); }); + + it("cloneOpenCodeProfileFromSeed mints distinct ids and copies the seed config (F36)", async () => { + const gw = new MockProfileGateway(); + + const a = await gw.cloneOpenCodeProfileFromSeed(); + const b = await gw.cloneOpenCodeProfileFromSeed(); + + expect(a.structuredAdapter).toBe("openCode"); + expect(a.opencode?.baseURL).toBe("http://localhost:8080/v1"); + // Identity is the id — two clones must never collide. + expect(a.id).not.toBe(b.id); + }); + + it("cloneOpenCodeProfileFromSeed honours name/config overrides (Duplicate path)", async () => { + const gw = new MockProfileGateway(); + const cloned = await gw.cloneOpenCodeProfileFromSeed({ + name: "My local model", + opencode: { + baseURL: "http://localhost:9999/v1", + model: "custom-model", + localModelServerId: "srv-7", + }, + }); + + expect(cloned.name).toBe("My local model"); + expect(cloned.opencode?.baseURL).toBe("http://localhost:9999/v1"); + expect(cloned.opencode?.model).toBe("custom-model"); + expect(cloned.opencode?.localModelServerId).toBe("srv-7"); + }); }); diff --git a/frontend/src/adapters/modelServer.ts b/frontend/src/adapters/modelServer.ts new file mode 100644 index 0000000..b2123b4 --- /dev/null +++ b/frontend/src/adapters/modelServer.ts @@ -0,0 +1,31 @@ +/** + * Tauri adapter for {@link ModelServerGateway} (F35). One of the only places that + * calls `invoke()`; features reach it exclusively through the port. + * + * Commands and payload keys are camelCase, matching the backend DTO convention. + * The flat `LocalModelServerConfig` wire shape is the DTO frozen by Architect — + * no nesting. + */ + +import { invoke } from "@tauri-apps/api/core"; + +import type { LocalModelServerConfig } from "@/domain"; +import type { ModelServerGateway } from "@/ports"; + +export class TauriModelServerGateway implements ModelServerGateway { + listModelServers(): Promise { + return invoke("list_model_servers"); + } + + saveModelServer( + config: LocalModelServerConfig, + ): Promise { + return invoke("save_model_server", { + request: { config }, + }); + } + + async deleteModelServer(serverId: string): Promise { + await invoke("delete_model_server", { serverId }); + } +} diff --git a/frontend/src/adapters/profile.ts b/frontend/src/adapters/profile.ts index da1edef..f15f038 100644 --- a/frontend/src/adapters/profile.ts +++ b/frontend/src/adapters/profile.ts @@ -9,7 +9,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { AgentProfile, FirstRunState, ProfileAvailability } from "@/domain"; -import type { ProfileGateway } from "@/ports"; +import type { CloneOpenCodeProfileFromSeedInput, ProfileGateway } from "@/ports"; export class TauriProfileGateway implements ProfileGateway { firstRunState(): Promise { @@ -43,4 +43,12 @@ export class TauriProfileGateway implements ProfileGateway { request: { profiles }, }); } + + cloneOpenCodeProfileFromSeed( + input: CloneOpenCodeProfileFromSeedInput = {}, + ): Promise { + return invoke("clone_opencode_profile_from_seed", { + request: { name: input.name, opencode: input.opencode }, + }); + } } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 1b48846..a74bc36 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -13,11 +13,92 @@ export interface HealthReport { note: string | null; } +/** + * Lifecycle status of a local model server during an agent launch (F35, mirror + * of the backend `ModelServerStatusDto`, tagged on `state`, camelCase wire). + * + * Contract note — the backend event carries the `serverId` on the *envelope* + * ({@link DomainEvent} `modelServerStatusChanged`), not inside the status, and it + * does NOT emit `baseURL`/`model` in the status payload: the UI reads what the + * backend actually sends. `reused` (on `ready`) tells apart a freshly-started + * server from a reused running one. + */ +export type ModelServerStatus = + | { state: "notConfigured" } + | { state: "probing" } + | { state: "starting" } + | { state: "ready"; reused: boolean } + | { state: "failed"; code: string; message: string }; + +/** + * Stop policy of a managed local model server (mirror of the backend + * `StopPolicyDto`, camelCase wire): + * - `keepAlive`: leave the process running, + * - `stopOnAppExit`: stop when IdeA exits (the effective default), + * - `stopWhenUnused`: stop when no profile references it. + */ +export type StopPolicy = "keepAlive" | "stopOnAppExit" | "stopWhenUnused"; + +/** + * A declared local model server (F35, mirror of the backend flat + * `LocalModelServerConfigDto`, camelCase wire). Global to IdeA (not project + * scoped). Referenced by an OpenCode profile through + * `OpenCodeConfig.localModelServerId` (= this `id`). + * + * Contract notes: + * - `id` is a UUID **minted client-side** on create (the backend requires a valid + * UUID; it does not generate the server id, only an internal model id it hides). + * - `servedModelName` is what the user configures and what is sent to OpenCode as + * the `model` — the backend's internal `model.id`/`model.label` are not exposed. + * - `modelPath` is required by the backend when `autoStart` is `true`. + */ +export interface LocalModelServerConfig { + id: string; + /** Only `llamaCpp` is supported today (camelCase, not "llamacpp"). */ + kind: "llamaCpp"; + name: string; + /** OpenAI-compatible base URL served by `llama-server` (normalised to `/v1`). */ + baseURL: string; + port: number; + /** Absolute `.gguf` path. Required when `autoStart` is `true`. */ + modelPath?: string; + /** Model name exposed by the server and sent to OpenCode as `model`. */ + servedModelName: string; + /** Explicit `llama-server` executable path/command (optional). */ + binaryPath?: string; + args: string[]; + autoStart: boolean; + stopPolicy: StopPolicy; +} + /** A domain event relayed from the backend (tagged union on `type`). */ export type DomainEvent = | { type: "projectCreated"; projectId: string } | { type: "agentLaunched"; agentId: string; sessionId: string } | { type: "agentExited"; agentId: string; code: number } + | { + /** + * A local model server changed lifecycle state while (re)launching an + * agent whose OpenCode profile binds `localModelServerId` (F35). Keyed by + * `serverId`; the frontend correlates it back to the agent through the + * profile's `opencode.localModelServerId`. + */ + type: "modelServerStatusChanged"; + serverId: string; + status: ModelServerStatus; + } + | { + /** + * An agent launch failed before a runtime session was created (F35). Unlike + * a generic "agent failed", this carries an actionable `message` and a + * stable `code`; `cause` namespaces the failure (e.g. `"model_server"`). + */ + type: "agentLaunchFailed"; + agentId: string; + cause: string; + code: string; + message: string; + } | { type: "agentProfileChanged"; agentId: string; profileId: string } | { type: "agentBusyChanged"; agentId: string; busy: boolean } | { @@ -687,6 +768,13 @@ export interface OpenCodeConfig { reasoning?: boolean; /** Enables attachments. Effective default: `false`. */ attachment?: boolean; + /** + * Optional id of a managed local model server (F35) this profile binds to. + * When set, IdeA can start/stop the referenced `llama-server` for the profile; + * omitted when the profile points at an externally-managed endpoint (mirrors + * the backend `skip_serializing_if`). Opaque string id on the wire. + */ + localModelServerId?: string; } /** diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 0416f2e..6eb865e 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -23,6 +23,7 @@ import { useDrift } from "@/features/templates/useDrift"; import { useGateways } from "@/app/di"; import { useAgents } from "./useAgents"; import { AgentLimitBadge } from "./AgentLimitBadge"; +import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge"; export interface AgentsPanelProps { /** The project whose agents to manage. */ @@ -331,6 +332,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { const delegationSource = vm.delegationSourceByRequester[a.id]; // Session-limit state (ARCHITECTURE §21), if the agent is limited. const limitState = vm.limitByAgent[a.id]; + // F35 — local model server launch state, correlated from the + // agent's profile binding (`opencode.localModelServerId`), plus the + // agent's last launch failure (if any). + const boundServerId = vm.profiles.find((p) => p.id === a.profileId) + ?.opencode?.localModelServerId; + const modelServerStatus = boundServerId + ? vm.modelServerStatusByServer[boundServerId] + : undefined; + const launchFailure = vm.launchFailureByAgent[a.id]; return (
  • )} + {/* F35 — local model server launch state / actionable failure. */} + + {/* F2 (ticket #4): announcements this agent is waiting on while it talks to another agent (requester == this row's id). Sits just above the agent drop-list. */} diff --git a/frontend/src/features/agents/ModelServerLaunchBadge.test.tsx b/frontend/src/features/agents/ModelServerLaunchBadge.test.tsx new file mode 100644 index 0000000..9e92b57 --- /dev/null +++ b/frontend/src/features/agents/ModelServerLaunchBadge.test.tsx @@ -0,0 +1,100 @@ +/** + * F35.1 — presentational tests for {@link ModelServerLaunchBadge}. Pure render + * of the folded launch state: lifecycle labels, the reused/fresh distinction, + * the actionable failure (with a details toggle that hides the code by default), + * and the "render nothing" cases for unmanaged rows. + */ + +import { describe, it, expect } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge"; + +describe("ModelServerLaunchBadge (F35.1)", () => { + it("renders nothing without a status or a failure", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing for a notConfigured status (unmanaged endpoint)", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("shows the probing and starting lifecycle labels", () => { + const { rerender } = render( + , + ); + expect(screen.getByLabelText("model server status").textContent).toMatch( + /vérification/i, + ); + rerender(); + expect(screen.getByLabelText("model server status").textContent).toMatch( + /démarrage/i, + ); + }); + + it("distinguishes a reused server from a freshly started one", () => { + const { rerender } = render( + , + ); + expect(screen.getByLabelText("model server status").textContent).toMatch( + /réutilisé/i, + ); + rerender( + , + ); + const label = screen.getByLabelText("model server status").textContent ?? ""; + expect(label).toMatch(/prêt/i); + expect(label).not.toMatch(/réutilisé/i); + }); + + it("shows an actionable failure message, not a bare 'agent failed'", () => { + render( + , + ); + const alert = screen.getByRole("alert"); + expect(alert.textContent).toMatch(/did not become ready on :8080/); + // The code is not exposed by default (logs/detail stay hidden). + expect(screen.queryByLabelText("launch failure detail")).toBeNull(); + }); + + it("reveals the stable code/cause only when Détails is toggled", () => { + render( + , + ); + fireEvent.click(screen.getByLabelText("toggle launch failure detail")); + const detail = screen.getByLabelText("launch failure detail"); + expect(detail.textContent).toMatch(/SPAWN/); + expect(detail.textContent).toMatch(/model_server/); + }); + + it("prefers the agent-scoped failure over a failed status", () => { + render( + , + ); + expect(screen.getByRole("alert").textContent).toMatch(/agent-level/); + }); + + it("falls back to a failed status when there is no agent failure", () => { + render( + , + ); + expect(screen.getByRole("alert").textContent).toMatch(/status-level boom/); + }); +}); diff --git a/frontend/src/features/agents/ModelServerLaunchBadge.tsx b/frontend/src/features/agents/ModelServerLaunchBadge.tsx new file mode 100644 index 0000000..5a3bfa1 --- /dev/null +++ b/frontend/src/features/agents/ModelServerLaunchBadge.tsx @@ -0,0 +1,115 @@ +/** + * `ModelServerLaunchBadge` — presentational launch-state indicator for an agent + * whose OpenCode profile binds a local model server (F35.1). All state is folded + * in {@link useAgents} (`modelServerStatusByServer` + `launchFailureByAgent`); + * this component only renders it. + * + * It shows, in order of severity: + * - a launch **failure** with its actionable message (not a bare "agent failed") + * — the full logs are never shown by default; a "Détails" toggle reveals the + * stable code/cause only; + * - otherwise the model-server lifecycle: `probing`, `starting`, `ready` + * (distinguishing a reused server from a freshly-started one). + * + * Renders nothing when there is neither a status to show nor a failure, so a + * plain (non-local-model) agent row stays untouched. + */ + +import { useState } from "react"; + +import { cn } from "@/shared"; +import type { ModelServerStatus } from "@/domain"; +import type { AgentLaunchFailure } from "./useAgents"; + +export interface ModelServerLaunchBadgeProps { + /** The bound server's lifecycle status, when one has been observed. */ + status?: ModelServerStatus; + /** The agent's last launch failure, when one occurred. */ + failure?: AgentLaunchFailure; +} + +/** Human label + tone for a non-failed lifecycle state. */ +function describeStatus( + status: ModelServerStatus, +): { label: string; tone: string; role?: "status" } | null { + switch (status.state) { + case "notConfigured": + return null; // Nothing to surface for an unmanaged endpoint. + case "probing": + return { label: "vérification du serveur…", tone: "bg-muted/20 text-muted", role: "status" }; + case "starting": + return { label: "démarrage du serveur…", tone: "bg-warning/20 text-warning", role: "status" }; + case "ready": + return status.reused + ? { label: "serveur prêt (réutilisé)", tone: "bg-success/20 text-success" } + : { label: "serveur prêt", tone: "bg-success/20 text-success" }; + case "failed": + // Handled by the failure branch below (richer, actionable). + return null; + } +} + +export function ModelServerLaunchBadge({ + status, + failure, +}: ModelServerLaunchBadgeProps) { + const [showDetail, setShowDetail] = useState(false); + + // A launch failure (from `agentLaunchFailed`) or a `failed` status both mean + // the launch could not complete — prefer the agent-scoped failure (it carries + // the actionable message); fall back to the server `failed` status. + const failed: { code: string; message: string; cause?: string } | null = + failure + ? failure + : status?.state === "failed" + ? { code: status.code, message: status.message } + : null; + + if (failed) { + return ( + + + Échec du lancement : {failed.message} + + + {showDetail && ( + + code {failed.code} + {failed.cause ? ` · cause ${failed.cause}` : ""} + + )} + + ); + } + + if (!status) return null; + const described = describeStatus(status); + if (!described) return null; + + return ( + + {described.label} + + ); +} diff --git a/frontend/src/features/agents/index.ts b/frontend/src/features/agents/index.ts index 35d7a69..929f070 100644 --- a/frontend/src/features/agents/index.ts +++ b/frontend/src/features/agents/index.ts @@ -5,7 +5,9 @@ export { AgentsPanel } from "./AgentsPanel"; export type { AgentsPanelProps } from "./AgentsPanel"; export { useAgents } from "./useAgents"; -export type { AgentsViewModel } from "./useAgents"; +export type { AgentsViewModel, AgentLaunchFailure } from "./useAgents"; +export { ModelServerLaunchBadge } from "./ModelServerLaunchBadge"; +export type { ModelServerLaunchBadgeProps } from "./ModelServerLaunchBadge"; export { ResumeProjectPanel } from "./ResumeProjectPanel"; export type { ResumeProjectPanelProps } from "./ResumeProjectPanel"; export { useResumeProject } from "./useResumeProject"; diff --git a/frontend/src/features/agents/useAgents.ts b/frontend/src/features/agents/useAgents.ts index 4e2a56e..d8234df 100644 --- a/frontend/src/features/agents/useAgents.ts +++ b/frontend/src/features/agents/useAgents.ts @@ -13,6 +13,7 @@ import type { Agent, AgentProfile, GatewayError, + ModelServerStatus, TerminalSession, } from "@/domain"; import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports"; @@ -41,6 +42,17 @@ export interface AgentLimitState { suspected?: boolean; } +/** + * An agent-launch failure surfaced to the UI (F35), from `agentLaunchFailed`. + * `cause` namespaces the origin (e.g. `"model_server"`); `code`/`message` are + * the stable, actionable failure details. + */ +export interface AgentLaunchFailure { + cause: string; + code: string; + message: string; +} + /** What the agents UI needs from this hook. */ export interface AgentsViewModel { /** All agents known to the project. */ @@ -67,6 +79,19 @@ export interface AgentsViewModel { * absent from the map is unlimited. */ limitByAgent: Record; + /** + * Local model-server lifecycle status keyed by `serverId` (F35), folded from + * `modelServerStatusChanged`. Correlate an agent to its server through the + * profile's `opencode.localModelServerId`. A server absent from the map has no + * observed status yet. + */ + modelServerStatusByServer: Record; + /** + * Last agent-launch failure keyed by agent id (F35), folded from + * `agentLaunchFailed`. Carries an actionable `message`/`code`/`cause`. Cleared + * for an agent as soon as it launches successfully (`agentLaunched`). + */ + launchFailureByAgent: Record; /** Last error message, or `null`. */ error: string | null; /** Whether a request is in flight. */ @@ -154,6 +179,12 @@ export function useAgents(projectId: string): AgentsViewModel { const [limitByAgent, setLimitByAgent] = useState< Record >({}); + const [modelServerStatusByServer, setModelServerStatusByServer] = useState< + Record + >({}); + const [launchFailureByAgent, setLaunchFailureByAgent] = useState< + Record + >({}); const refresh = useCallback(async () => { setBusy(true); @@ -208,6 +239,15 @@ export function useAgents(projectId: string): AgentsViewModel { void refresh(); void refreshLiveAgents(); } + // A successful launch supersedes any prior launch failure for that agent + // (F35): drop its stale error banner. + if (event.type === "agentLaunched") { + setLaunchFailureByAgent((prev) => { + if (!(event.agentId in prev)) return prev; + const { [event.agentId]: _cleared, ...rest } = prev; + return rest; + }); + } // Record which door a delegation came through (mcp vs file). Absent // `source` (older backend) leaves the map untouched → no badge. if ( @@ -269,6 +309,24 @@ export function useAgents(projectId: string): AgentsViewModel { }, })); break; + // F35 — local model server lifecycle during launch, keyed by server id. + case "modelServerStatusChanged": + setModelServerStatusByServer((prev) => ({ + ...prev, + [event.serverId]: event.status, + })); + break; + // F35 — a launch failed with an actionable cause/message, keyed by agent. + case "agentLaunchFailed": + setLaunchFailureByAgent((prev) => ({ + ...prev, + [event.agentId]: { + cause: event.cause, + code: event.code, + message: event.message, + }, + })); + break; default: break; } @@ -475,6 +533,8 @@ export function useAgents(projectId: string): AgentsViewModel { liveAgents, delegationSourceByRequester, limitByAgent, + modelServerStatusByServer, + launchFailureByAgent, error, busy, runningAgentId, diff --git a/frontend/src/features/agents/useAgentsModelServer.test.tsx b/frontend/src/features/agents/useAgentsModelServer.test.tsx new file mode 100644 index 0000000..9ff8ec9 --- /dev/null +++ b/frontend/src/features/agents/useAgentsModelServer.test.tsx @@ -0,0 +1,129 @@ +/** + * F35.1 — local model server launch state in {@link useAgents}. + * + * Drives the hook behind the real {@link DIProvider} with an in-memory + * {@link MockSystemGateway} to emit the two F35 domain events, and verifies they + * fold into `modelServerStatusByServer` (keyed by serverId) and + * `launchFailureByAgent` (keyed by agentId). + * + * Cases: + * - `modelServerStatusChanged` → status by server id (probing → starting → ready) + * - `agentLaunchFailed` → actionable failure by agent id + * - `agentLaunched` clears a prior launch failure for that agent + * - a `failed` status is retained per server (independent of the agent map) + */ + +import { describe, it, expect } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +import type { DomainEvent } from "@/domain"; +import type { Gateways } from "@/ports"; +import { MockSystemGateway } from "@/adapters/mock"; +import { DIProvider } from "@/app/di"; +import { useAgents } from "./useAgents"; + +const PROJECT_ID = "proj-modelserver-001"; +const AGENT = "agent-oc"; +const SERVER = "srv-42"; + +function setup() { + const system = new MockSystemGateway(); + const agent = { + listAgents: async () => [], + listLiveAgents: async () => [], + }; + const profile = { listProfiles: async () => [] }; + const gateways = { system, agent, profile } as unknown as Gateways; + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const view = renderHook(() => useAgents(PROJECT_ID), { wrapper }); + return { system, view }; +} + +async function emit(system: MockSystemGateway, event: DomainEvent) { + await act(async () => { + system.emit(event); + await Promise.resolve(); + }); +} + +describe("useAgents — local model server launch state (F35.1)", () => { + it("folds modelServerStatusChanged into modelServerStatusByServer by serverId", async () => { + const { system, view } = setup(); + + await emit(system, { + type: "modelServerStatusChanged", + serverId: SERVER, + status: { state: "probing" }, + }); + expect(view.result.current.modelServerStatusByServer[SERVER]).toEqual({ + state: "probing", + }); + + await emit(system, { + type: "modelServerStatusChanged", + serverId: SERVER, + status: { state: "starting" }, + }); + await emit(system, { + type: "modelServerStatusChanged", + serverId: SERVER, + status: { state: "ready", reused: true }, + }); + expect(view.result.current.modelServerStatusByServer[SERVER]).toEqual({ + state: "ready", + reused: true, + }); + }); + + it("folds agentLaunchFailed into launchFailureByAgent by agentId", async () => { + const { system, view } = setup(); + await emit(system, { + type: "agentLaunchFailed", + agentId: AGENT, + cause: "model_server", + code: "SERVER_UNREACHABLE", + message: "llama-server did not become ready on :8080", + }); + expect(view.result.current.launchFailureByAgent[AGENT]).toEqual({ + cause: "model_server", + code: "SERVER_UNREACHABLE", + message: "llama-server did not become ready on :8080", + }); + }); + + it("a successful agentLaunched clears the agent's prior launch failure", async () => { + const { system, view } = setup(); + await emit(system, { + type: "agentLaunchFailed", + agentId: AGENT, + cause: "model_server", + code: "SERVER_UNREACHABLE", + message: "boom", + }); + expect(view.result.current.launchFailureByAgent[AGENT]).toBeTruthy(); + + await emit(system, { + type: "agentLaunched", + agentId: AGENT, + sessionId: "sess-1", + }); + expect(view.result.current.launchFailureByAgent[AGENT]).toBeUndefined(); + }); + + it("keeps a per-server failed status independent of the agent failure map", async () => { + const { system, view } = setup(); + await emit(system, { + type: "modelServerStatusChanged", + serverId: SERVER, + status: { state: "failed", code: "SPAWN", message: "binary not found" }, + }); + expect(view.result.current.modelServerStatusByServer[SERVER]).toEqual({ + state: "failed", + code: "SPAWN", + message: "binary not found", + }); + expect(view.result.current.launchFailureByAgent[AGENT]).toBeUndefined(); + }); +}); diff --git a/frontend/src/features/first-run/FirstRunWizard.test.tsx b/frontend/src/features/first-run/FirstRunWizard.test.tsx index 22657f2..893ec28 100644 --- a/frontend/src/features/first-run/FirstRunWizard.test.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.test.tsx @@ -13,8 +13,8 @@ import { fireEvent, } from "@testing-library/react"; -import { MockProfileGateway } from "@/adapters/mock"; -import type { ProfileAvailability } from "@/domain"; +import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock"; +import type { LocalModelServerConfig, ProfileAvailability } from "@/domain"; import type { Gateways } from "@/ports"; import { DIProvider } from "@/app/di"; import { FirstRunWizard } from "./FirstRunWizard"; @@ -23,10 +23,12 @@ import { DETECT_TIMEOUT_MS } from "./useFirstRun"; function renderWizard( profile: MockProfileGateway = new MockProfileGateway(), onDone = vi.fn(), + modelServer: MockModelServerGateway = new MockModelServerGateway(), ) { - const gateways = { profile } as unknown as Gateways; + const gateways = { profile, modelServer } as unknown as Gateways; return { profile, + modelServer, onDone, ...render( @@ -277,6 +279,182 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => { }); }); +describe("FirstRunWizard — several local OpenCode profiles (F36)", () => { + const OPENCODE = "OpenCode + llama.cpp"; + const CLONE1 = `${OPENCODE} (copy 1)`; + + it('"Add OpenCode profile" clones the seed into a new, editable, pre-selected row', async () => { + renderWizard(); + await waitForLoaded(); + + // Only one OpenCode row to begin with (the seed reference). + expect(screen.getAllByText(OPENCODE).length).toBe(1); + + fireEvent.click( + screen.getByRole("button", { name: "Add OpenCode profile" }), + ); + + // A second OpenCode row appears, pre-filled from the seed and pre-selected. + const added = await screen.findByLabelText(`use ${CLONE1}`); + expect((added as HTMLInputElement).checked).toBe(true); + expect( + (screen.getByLabelText(`${CLONE1} base url`) as HTMLInputElement).value, + ).toBe("http://localhost:8080/v1"); + // Its name is editable per profile (identity is the id, not the name). + expect(screen.getByLabelText(`${CLONE1} name`)).toBeTruthy(); + }); + + it("persists two distinct OpenCode profiles when both are selected", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + + // Select the seed row and edit it. + fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`)); + fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), { + target: { value: "qwen3-coder-14b" }, + }); + + // Add a second one and give it a different endpoint. + fireEvent.click( + screen.getByRole("button", { name: "Add OpenCode profile" }), + ); + await screen.findByLabelText(`use ${CLONE1}`); + fireEvent.change(screen.getByLabelText(`${CLONE1} base url`), { + target: { value: "http://localhost:9191/v1" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + const opencode = saved.filter((p) => p.structuredAdapter === "openCode"); + expect(opencode.length).toBe(2); + // Distinct ids (identity) and distinct endpoints. + expect(new Set(opencode.map((p) => p.id)).size).toBe(2); + expect(opencode.map((p) => p.opencode?.baseURL).sort()).toEqual([ + "http://localhost:8080/v1", + "http://localhost:9191/v1", + ]); + }); + }); + + it("Duplicate clones a row carrying its current config over", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + + // Edit the seed row, then duplicate it. + fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`)); + fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), { + target: { value: "qwen3-coder-7b" }, + }); + fireEvent.click( + screen.getByRole("button", { name: `duplicate ${OPENCODE}` }), + ); + + const dupName = `${OPENCODE} (copy)`; + await screen.findByLabelText(`use ${dupName}`); + // The duplicate inherits the edited model. + expect( + (screen.getByLabelText(`${dupName} model`) as HTMLInputElement).value, + ).toBe("qwen3-coder-7b"); + + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + await waitFor(async () => { + const saved = await profile.listProfiles(); + const models = saved + .filter((p) => p.structuredAdapter === "openCode") + .map((p) => p.opencode?.model); + expect(models).toEqual(["qwen3-coder-7b", "qwen3-coder-7b"]); + }); + }); + + it("editing the name round-trips on save", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + + fireEvent.click( + screen.getByRole("button", { name: "Add OpenCode profile" }), + ); + await screen.findByLabelText(`use ${CLONE1}`); + fireEvent.change(screen.getByLabelText(`${CLONE1} name`), { + target: { value: "Fast local model" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + await waitFor(async () => { + const saved = await profile.listProfiles(); + const oc = saved.find((p) => p.structuredAdapter === "openCode"); + expect(oc?.name).toBe("Fast local model"); + }); + }); + + it("reasoning/attachment round-trip on save (F36)", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + + fireEvent.click( + screen.getByRole("button", { name: "Add OpenCode profile" }), + ); + await screen.findByLabelText(`use ${CLONE1}`); + + // Reasoning defaults on (backend effective default true); turn it off. + const reasoning = screen.getByLabelText( + `${CLONE1} reasoning`, + ) as HTMLInputElement; + expect(reasoning.checked).toBe(true); + fireEvent.click(reasoning); + // Attachments default off; turn them on. + fireEvent.click(screen.getByLabelText(`${CLONE1} attachment`)); + + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + await waitFor(async () => { + const saved = await profile.listProfiles(); + const oc = saved.find((p) => p.structuredAdapter === "openCode"); + expect(oc?.opencode?.reasoning).toBe(false); + expect(oc?.opencode?.attachment).toBe(true); + }); + }); + + it("binds localModelServerId via the server dropdown (F35.2), replacing free text", async () => { + // Seed a declared server so the dropdown offers it. + const modelServer = new MockModelServerGateway(); + const server: LocalModelServerConfig = { + id: "550e8400-e29b-41d4-a716-446655440000", + kind: "llamaCpp", + name: "Local A", + baseURL: "http://localhost:8080/v1", + port: 8080, + servedModelName: "qwen3-coder-30b", + args: [], + autoStart: false, + stopPolicy: "stopOnAppExit", + }; + await modelServer.saveModelServer(server); + + const { profile } = renderWizard(new MockProfileGateway(), vi.fn(), modelServer); + await waitForLoaded(); + + fireEvent.click( + screen.getByRole("button", { name: "Add OpenCode profile" }), + ); + await screen.findByLabelText(`use ${CLONE1}`); + + // The old free-text field is gone; a dropdown replaces it. + expect( + screen.queryByLabelText(`${CLONE1} local model server id`), + ).toBeNull(); + const select = await screen.findByLabelText(`${CLONE1} local model server`); + fireEvent.change(select, { target: { value: server.id } }); + + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + await waitFor(async () => { + const saved = await profile.listProfiles(); + const oc = saved.find((p) => p.structuredAdapter === "openCode"); + expect(oc?.opencode?.localModelServerId).toBe(server.id); + }); + }); +}); + // Ticket #28 — the wizard must survive a detection that never answers. When the // backend `detect_profiles` command panics, the `invoke` promise is neither // resolved nor rejected: nothing after `await detectProfiles(...)` ever runs. diff --git a/frontend/src/features/first-run/FirstRunWizard.tsx b/frontend/src/features/first-run/FirstRunWizard.tsx index e7ed7a1..3d89460 100644 --- a/frontend/src/features/first-run/FirstRunWizard.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.tsx @@ -15,8 +15,18 @@ * `./profile`. */ -import type { AgentProfile, HttpChatConfig, OpenCodeConfig } from "@/domain"; +import type { + AgentProfile, + HttpChatConfig, + LocalModelServerConfig, + OpenCodeConfig, +} from "@/domain"; import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared"; +import { + ModelServersPanel, + ModelServerSelect, + useModelServers, +} from "@/features/model-servers"; import { useFirstRun, type WizardEntry } from "./useFirstRun"; import { defaultHttpChatConfig, @@ -48,6 +58,7 @@ export function FirstRunWizard({ forceOpen?: boolean; }) { const vm = useFirstRun(); + const modelServers = useModelServers(); if (vm.isFirstRun === null) return null; if (!forceOpen && vm.isFirstRun === false) return null; @@ -93,16 +104,40 @@ export function FirstRunWizard({ Detecting… )} + {/* F36: declare several local OpenCode profiles. Each click clones the + canonical `opencode-llamacpp` seed into a new, editable row. */} + + {/* F35.2 — declare/edit/delete the local llama.cpp servers an OpenCode + profile can bind to. Sits above the profile list so a server exists + before it is picked in the OpenCode dropdown. */} + +
      {vm.entries.map((entry) => ( vm.toggle(entry.profile.id)} onChange={(p) => vm.updateProfile(entry.profile.id, p)} onRemove={() => vm.remove(entry.profile.id)} + onDuplicate={ + entry.profile.structuredAdapter === "openCode" + ? () => + vm.addOpenCodeProfile({ + name: `${entry.profile.name} (copy)`, + opencode: entry.profile.opencode, + }) + : undefined + } /> ))}
    @@ -120,17 +155,24 @@ export function FirstRunWizard({ /** One editable candidate row: select, edit command/args, see availability. */ function ProfileRow({ entry, + servers, onToggle, onChange, onRemove, + onDuplicate, }: { entry: WizardEntry; + /** Declared local model servers (F35.2), for the OpenCode binding dropdown. */ + servers: LocalModelServerConfig[]; onToggle: () => void; onChange: (p: AgentProfile) => void; onRemove: () => void; + /** Present only for OpenCode rows: clone this row into a new profile (F36). */ + onDuplicate?: () => void; }) { const { profile, selected, available } = entry; const errors = validateProfile(profile); + const isOpenCode = profile.structuredAdapter === "openCode"; return (
  • @@ -158,16 +200,41 @@ function ProfileRow({ > {available === null ? "—" : available ? "✓ installed" : "✗ not found"} - - × - +
    + {onDuplicate && ( + + )} + + × + +
    + {/* F36: an OpenCode profile's name is identity-neutral but user-facing, so + it is editable per profile (several local models coexist). */} + {isOpenCode && ( + + )} +
  • ); @@ -208,10 +280,13 @@ function ProfileRow({ function OpenCodeFields({ profile, errors, + servers, onChange, }: { profile: AgentProfile; errors: ProfileErrors; + /** Declared local model servers (F35.2), for the binding dropdown. */ + servers: LocalModelServerConfig[]; onChange: (p: AgentProfile) => void; }) { const opencode = profile.opencode ?? defaultOpenCodeConfig(); @@ -262,6 +337,44 @@ function OpenCodeFields({ }} /> + +
    + + + +
    + + ); } diff --git a/frontend/src/features/first-run/useFirstRun.ts b/frontend/src/features/first-run/useFirstRun.ts index ff7b9eb..0cc1f8a 100644 --- a/frontend/src/features/first-run/useFirstRun.ts +++ b/frontend/src/features/first-run/useFirstRun.ts @@ -19,6 +19,7 @@ import type { AgentProfile, FirstRunState, GatewayError, + OpenCodeConfig, ProfileAvailability, } from "@/domain"; import { useGateways } from "@/app/di"; @@ -50,6 +51,15 @@ export interface FirstRunViewModel { toggle: (id: string) => void; /** Replaces a candidate's profile (edited command/args/injection). */ updateProfile: (id: string, profile: AgentProfile) => void; + /** + * Mints a new OpenCode profile from the seed (F36 — several local OpenCode + * profiles) and appends it, pre-selected, to the rows. Optionally overrides the + * name/config (used by "Duplicate" to carry a row's endpoint over). + */ + addOpenCodeProfile: (input?: { + name?: string; + opencode?: OpenCodeConfig; + }) => Promise; /** Removes a candidate by id. */ remove: (id: string) => void; /** Runs detection over all candidates, filling availability. */ @@ -187,6 +197,24 @@ export function useFirstRun(): FirstRunViewModel { ); }, []); + const addOpenCodeProfile = useCallback( + async (input?: { name?: string; opencode?: OpenCodeConfig }) => { + setError(null); + try { + const created = await profile.cloneOpenCodeProfileFromSeed(input); + // Freshly cloned profiles start selected (the user opted in by adding + // one) and available=null (detection re-probes them on demand). + setEntries((prev) => [ + ...prev, + { profile: created, selected: true, available: null }, + ]); + } catch (e) { + setError(describe(e)); + } + }, + [profile], + ); + const remove = useCallback((id: string) => { setEntries((prev) => prev.filter((e) => e.profile.id !== id)); }, []); @@ -221,6 +249,7 @@ export function useFirstRun(): FirstRunViewModel { detecting, toggle, updateProfile, + addOpenCodeProfile, remove, detect, finish, diff --git a/frontend/src/features/model-servers/ModelServerSelect.tsx b/frontend/src/features/model-servers/ModelServerSelect.tsx new file mode 100644 index 0000000..e36295a --- /dev/null +++ b/frontend/src/features/model-servers/ModelServerSelect.tsx @@ -0,0 +1,57 @@ +/** + * `ModelServerSelect` (F35.2) — a dropdown to bind an OpenCode profile to one of + * the declared local model servers, or to none ("external endpoint"). Replaces + * the free-text `localModelServerId` input posed in F36. Presentational: the + * server list and the current value are passed in. + */ + +import type { LocalModelServerConfig } from "@/domain"; +import { cn } from "@/shared"; + +/** The sentinel option value standing for "no managed server". */ +const NONE = ""; + +export interface ModelServerSelectProps { + /** The declared servers to choose from. */ + servers: LocalModelServerConfig[]; + /** The currently-bound server id, or `undefined` for none. */ + value?: string; + /** Called with the new server id, or `undefined` when "none" is chosen. */ + onChange: (serverId: string | undefined) => void; + /** Accessible label (defaults to a generic one). */ + ariaLabel?: string; +} + +export function ModelServerSelect({ + servers, + value, + onChange, + ariaLabel = "local model server", +}: ModelServerSelectProps) { + // A previously-bound server that no longer exists (deleted): keep it visible + // so the binding isn't silently dropped from the UI. + const missing = + value !== undefined && !servers.some((s) => s.id === value); + + return ( + + ); +} diff --git a/frontend/src/features/model-servers/ModelServersPanel.tsx b/frontend/src/features/model-servers/ModelServersPanel.tsx new file mode 100644 index 0000000..2c3b968 --- /dev/null +++ b/frontend/src/features/model-servers/ModelServersPanel.tsx @@ -0,0 +1,323 @@ +/** + * `ModelServersPanel` (F35.2) — declare / edit / delete the local `llama.cpp` + * servers an OpenCode profile can bind to. Presentational: all I/O state comes + * from a {@link ModelServersViewModel} passed in (`useModelServers`), so the + * panel renders and is testable in isolation. + * + * A single editable draft is shown at a time (add or edit); the list underneath + * offers Edit / Delete per server. The `model_server_in_use` rejection surfaces + * as the vm's error banner (a referenced server can't be removed). + */ + +import { useState } from "react"; + +import type { LocalModelServerConfig, StopPolicy } from "@/domain"; +import { Button, IconButton, Input, Panel, cn } from "@/shared"; +import type { ModelServersViewModel } from "./useModelServers"; +import { + emptyModelServer, + parseArgs, + validateModelServer, + type ModelServerErrors, +} from "./modelServer"; + +/** Small caption above a control. */ +function Caption({ children }: { children: React.ReactNode }) { + return {children}; +} + +const STOP_POLICIES: { value: StopPolicy; label: string }[] = [ + { value: "stopOnAppExit", label: "Stop on app exit" }, + { value: "keepAlive", label: "Keep alive" }, + { value: "stopWhenUnused", label: "Stop when unused" }, +]; + +export interface ModelServersPanelProps { + /** The model-server registry view-model (from `useModelServers`). */ + vm: ModelServersViewModel; +} + +export function ModelServersPanel({ vm }: ModelServersPanelProps) { + // The server being edited, or a fresh draft when "Add" is clicked. `null` ⇒ + // no editor open (list-only). + const [draft, setDraft] = useState(null); + + function startAdd() { + vm.clearError(); + setDraft(emptyModelServer()); + } + + function startEdit(server: LocalModelServerConfig) { + vm.clearError(); + setDraft({ ...server }); + } + + async function submit() { + if (!draft) return; + const saved = await vm.save(draft); + if (saved) setDraft(null); + } + + return ( + +

    + Local model servers +

    + + + } + > +
    + {vm.error && ( +

    + {vm.error} +

    + )} + + {vm.servers.length === 0 && !draft && ( +

    + No local server declared yet. Add one to auto-manage a `llama-server`, + or keep using an external endpoint. +

    + )} + +
      + {vm.servers.map((server) => ( +
    • + + + {server.name} + + + {server.baseURL} · {server.servedModelName} + {server.autoStart ? " · auto-start" : ""} + + + + + void vm.remove(server.id)} + disabled={vm.busy} + > + × + + +
    • + ))} +
    + + {draft && ( + { + vm.clearError(); + setDraft(null); + }} + onSubmit={() => void submit()} + /> + )} +
    +
    + ); +} + +/** The add/edit form for one server draft. */ +function ServerEditor({ + draft, + busy, + onChange, + onCancel, + onSubmit, +}: { + draft: LocalModelServerConfig; + busy: boolean; + onChange: (next: LocalModelServerConfig) => void; + onCancel: () => void; + onSubmit: () => void; +}) { + const errors: ModelServerErrors = validateModelServer(draft); + const valid = Object.keys(errors).length === 0; + const patch = (next: Partial) => + onChange({ ...draft, ...next }); + + return ( +
    + + {draft.name.trim().length > 0 ? draft.name : "New server"} + + + + +
    + + + +
    + + + + + + + + + +
    + + + +
    + +
    + + +
    +
    + ); +} diff --git a/frontend/src/features/model-servers/index.ts b/frontend/src/features/model-servers/index.ts new file mode 100644 index 0000000..f308348 --- /dev/null +++ b/frontend/src/features/model-servers/index.ts @@ -0,0 +1,19 @@ +/** + * Local model servers feature — public surface (F35.2). + */ + +export { useModelServers } from "./useModelServers"; +export type { ModelServersViewModel } from "./useModelServers"; +export { ModelServersPanel } from "./ModelServersPanel"; +export type { ModelServersPanelProps } from "./ModelServersPanel"; +export { ModelServerSelect } from "./ModelServerSelect"; +export type { ModelServerSelectProps } from "./ModelServerSelect"; +export { + emptyModelServer, + newModelServerId, + validateModelServer, + isModelServerValid, + isAbsolutePath, + parseArgs, + type ModelServerErrors, +} from "./modelServer"; diff --git a/frontend/src/features/model-servers/modelServer.ts b/frontend/src/features/model-servers/modelServer.ts new file mode 100644 index 0000000..75614c6 --- /dev/null +++ b/frontend/src/features/model-servers/modelServer.ts @@ -0,0 +1,97 @@ +/** + * Pure, framework-free helpers for the local model server feature (F35). Draft + * validation mirrors the backend invariants (`ModelServerEndpoint::new`, + * `LocalModelRef::new`, `LocalModelServerConfig::new`) so the form can surface + * errors before any `invoke`. No React, no Tauri here. + */ + +import type { LocalModelServerConfig } from "@/domain"; + +/** A field-keyed validation error map (empty ⇒ valid). */ +export type ModelServerErrors = Partial< + Record< + "name" | "baseURL" | "port" | "servedModelName" | "modelPath", + string + > +>; + +/** Whether a string is a valid `http://`/`https://` URL (mirror of backend). */ +function isValidHttpUrl(v: string): boolean { + if (v.trim().length === 0) return false; + return v.startsWith("http://") || v.startsWith("https://"); +} + +/** + * Whether a path looks absolute (mirror of the backend `is_absolute_path`: + * POSIX `/…` or a Windows drive/UNC). Blank is handled by the caller. + */ +export function isAbsolutePath(path: string): boolean { + if (path.startsWith("/") || path.startsWith("\\")) return true; + // Windows drive `C:/` or `C:\`. + return /^[a-zA-Z]:[/\\]/.test(path); +} + +/** + * Validates a {@link LocalModelServerConfig} draft the way the backend would. + * Returns an empty object when the draft is valid. + */ +export function validateModelServer(c: LocalModelServerConfig): ModelServerErrors { + const errors: ModelServerErrors = {}; + if (c.name.trim().length === 0) errors.name = "Name is required."; + if (!isValidHttpUrl(c.baseURL)) { + errors.baseURL = "Base URL must start with http:// or https://."; + } + if (!Number.isInteger(c.port) || c.port < 1 || c.port > 65_535) { + errors.port = "Port must be an integer between 1 and 65535."; + } + if (c.servedModelName.trim().length === 0) { + errors.servedModelName = "Served model name is required."; + } + const modelPath = c.modelPath?.trim() ?? ""; + if (modelPath.length > 0 && !isAbsolutePath(modelPath)) { + errors.modelPath = "Model path must be absolute."; + } + // The backend rejects auto-start without an absolute model path. + if (c.autoStart && modelPath.length === 0) { + errors.modelPath = "Auto-start requires an absolute model path."; + } + return errors; +} + +/** Whether a draft is valid (no errors). */ +export function isModelServerValid(c: LocalModelServerConfig): boolean { + return Object.keys(validateModelServer(c)).length === 0; +} + +/** Generates a UUID for a client-created server (crypto when available). */ +export function newModelServerId(): string { + const c = globalThis.crypto as Crypto | undefined; + if (c && typeof c.randomUUID === "function") return c.randomUUID(); + return `srv-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * A fresh model-server draft (id minted client-side) pre-filled with a sensible + * local `llama-server` endpoint, so the form starts valid apart from the name. + */ +export function emptyModelServer(): LocalModelServerConfig { + return { + id: newModelServerId(), + kind: "llamaCpp", + name: "", + baseURL: "http://localhost:8080/v1", + port: 8080, + servedModelName: "qwen3-coder-30b", + args: [], + autoStart: false, + stopPolicy: "stopOnAppExit", + }; +} + +/** Parses a whitespace-separated args string into a trimmed, non-empty list. */ +export function parseArgs(raw: string): string[] { + return raw + .split(/\s+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} diff --git a/frontend/src/features/model-servers/modelServers.test.tsx b/frontend/src/features/model-servers/modelServers.test.tsx new file mode 100644 index 0000000..1da2adc --- /dev/null +++ b/frontend/src/features/model-servers/modelServers.test.tsx @@ -0,0 +1,251 @@ +/** + * F35.2 — local model server registry: the pure validation helpers, the + * {@link useModelServers} hook behind the real DIProvider + mock gateway, the + * {@link ModelServersPanel} CRUD UI (incl. the `model_server_in_use` rejection), + * and the {@link ModelServerSelect} binding dropdown. + */ + +import { describe, it, expect } from "vitest"; +import { act, render, renderHook, screen, fireEvent, waitFor } from "@testing-library/react"; + +import type { Gateways } from "@/ports"; +import type { LocalModelServerConfig } from "@/domain"; +import { MockModelServerGateway } from "@/adapters/mock"; +import { DIProvider } from "@/app/di"; +import { useModelServers } from "./useModelServers"; +import { ModelServersPanel } from "./ModelServersPanel"; +import { ModelServerSelect } from "./ModelServerSelect"; +import { + emptyModelServer, + isAbsolutePath, + validateModelServer, +} from "./modelServer"; + +const SERVER: LocalModelServerConfig = { + id: "550e8400-e29b-41d4-a716-446655440000", + kind: "llamaCpp", + name: "Local A", + baseURL: "http://localhost:8080/v1", + port: 8080, + servedModelName: "qwen3-coder-30b", + args: [], + autoStart: false, + stopPolicy: "stopOnAppExit", +}; + +function setup(modelServer = new MockModelServerGateway()) { + const gateways = { modelServer } as unknown as Gateways; + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const view = renderHook(() => useModelServers(), { wrapper }); + return { modelServer, view }; +} + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +describe("modelServer helpers (F35.2)", () => { + it("emptyModelServer starts valid apart from the name", () => { + const draft = emptyModelServer(); + expect(validateModelServer(draft)).toEqual({ name: "Name is required." }); + }); + + it("flags a non-http base URL and an out-of-range port", () => { + const errors = validateModelServer({ + ...SERVER, + baseURL: "ftp://oops", + port: 70_000, + }); + expect(errors.baseURL).toBeTruthy(); + expect(errors.port).toBeTruthy(); + }); + + it("requires an absolute model path, and requires it when auto-start is on", () => { + expect(validateModelServer({ ...SERVER, modelPath: "relative/x.gguf" }).modelPath).toBeTruthy(); + expect(validateModelServer({ ...SERVER, autoStart: true }).modelPath).toMatch( + /auto-start/i, + ); + expect( + validateModelServer({ ...SERVER, autoStart: true, modelPath: "/m/x.gguf" }).modelPath, + ).toBeUndefined(); + }); + + it("recognises absolute POSIX and Windows paths", () => { + expect(isAbsolutePath("/models/x.gguf")).toBe(true); + expect(isAbsolutePath("C:/models/x.gguf")).toBe(true); + expect(isAbsolutePath("models/x.gguf")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +describe("useModelServers (F35.2)", () => { + it("saves and lists servers", async () => { + const { view } = setup(); + await act(async () => { + await view.result.current.save(SERVER); + }); + expect(view.result.current.servers).toHaveLength(1); + expect(view.result.current.servers[0].name).toBe("Local A"); + }); + + it("removes a server", async () => { + const { view } = setup(); + await act(async () => { + await view.result.current.save(SERVER); + }); + await act(async () => { + const ok = await view.result.current.remove(SERVER.id); + expect(ok).toBe(true); + }); + expect(view.result.current.servers).toHaveLength(0); + }); + + it("surfaces model_server_in_use as an actionable error and keeps the server", async () => { + const modelServer = new MockModelServerGateway(); + const { view } = setup(modelServer); + await act(async () => { + await view.result.current.save(SERVER); + }); + modelServer.markInUse(SERVER.id); + await act(async () => { + const ok = await view.result.current.remove(SERVER.id); + expect(ok).toBe(false); + }); + expect(view.result.current.error).toMatch(/encore utilisé par un profil/i); + expect(view.result.current.servers).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// Panel +// --------------------------------------------------------------------------- + +function renderPanel(modelServer = new MockModelServerGateway()) { + const gateways = { modelServer } as unknown as Gateways; + function Harness() { + const vm = useModelServers(); + return ; + } + return { + modelServer, + ...render( + + + , + ), + }; +} + +describe("ModelServersPanel (F35.2)", () => { + it("declares a new server end-to-end", async () => { + const { modelServer } = renderPanel(); + fireEvent.click(screen.getByLabelText("add model server")); + + fireEvent.change(screen.getByLabelText("server name"), { + target: { value: "Local A" }, + }); + // A valid draft (pre-filled endpoint + servedModelName) enables Save once + // the initial passive load has settled. + const save = screen.getByLabelText("save model server") as HTMLButtonElement; + await waitFor(() => expect(save.disabled).toBe(false)); + fireEvent.click(save); + + await waitFor(async () => { + const saved = await modelServer.listModelServers(); + expect(saved.map((s) => s.name)).toEqual(["Local A"]); + }); + // The editor closed and the server shows in the list. + expect(screen.getByLabelText("edit Local A")).toBeTruthy(); + }); + + it("blocks Save while the draft is invalid (blank name)", () => { + renderPanel(); + fireEvent.click(screen.getByLabelText("add model server")); + // Invalid draft (blank name) keeps Save disabled regardless of busy. + expect( + (screen.getByLabelText("save model server") as HTMLButtonElement).disabled, + ).toBe(true); + }); + + it("shows an actionable banner when deleting a referenced server", async () => { + const modelServer = new MockModelServerGateway(); + await modelServer.saveModelServer(SERVER); + modelServer.markInUse(SERVER.id); + renderPanel(modelServer); + + await screen.findByLabelText("edit Local A"); + fireEvent.click(screen.getByLabelText("delete Local A")); + + expect((await screen.findByRole("alert")).textContent).toMatch( + /encore utilisé par un profil/i, + ); + // The server is still listed (delete was refused). + expect(screen.getByLabelText("edit Local A")).toBeTruthy(); + }); + + it("edits an existing server's served model name", async () => { + const modelServer = new MockModelServerGateway(); + await modelServer.saveModelServer(SERVER); + renderPanel(modelServer); + + fireEvent.click(await screen.findByLabelText("edit Local A")); + fireEvent.change(screen.getByLabelText("server served model name"), { + target: { value: "qwen3-coder-7b" }, + }); + fireEvent.click(screen.getByLabelText("save model server")); + + await waitFor(async () => { + const saved = await modelServer.listModelServers(); + expect(saved[0].servedModelName).toBe("qwen3-coder-7b"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Select +// --------------------------------------------------------------------------- + +describe("ModelServerSelect (F35.2)", () => { + it("offers a 'none' option plus each declared server, and emits the id", () => { + let picked: string | undefined = "sentinel"; + render( + (picked = id)} + />, + ); + const select = screen.getByLabelText("local model server") as HTMLSelectElement; + // "None" is selected when value is undefined. + expect(select.value).toBe(""); + fireEvent.change(select, { target: { value: SERVER.id } }); + expect(picked).toBe(SERVER.id); + }); + + it("emits undefined when 'none' is chosen", () => { + let picked: string | undefined = SERVER.id; + render( + (picked = id)} + />, + ); + fireEvent.change(screen.getByLabelText("local model server"), { + target: { value: "" }, + }); + expect(picked).toBeUndefined(); + }); + + it("keeps a removed/unknown bound id visible rather than dropping it", () => { + render( + {}} />, + ); + expect(screen.getByText(/ghost-id \(unknown \/ removed\)/)).toBeTruthy(); + }); +}); diff --git a/frontend/src/features/model-servers/useModelServers.ts b/frontend/src/features/model-servers/useModelServers.ts new file mode 100644 index 0000000..7f4f561 --- /dev/null +++ b/frontend/src/features/model-servers/useModelServers.ts @@ -0,0 +1,126 @@ +/** + * `useModelServers` — view-model for the local model server registry (F35.2). + * Owns the declared-servers list and the CRUD actions, consuming the + * {@link ModelServerGateway} exclusively (never `invoke()`), so the UI is + * testable with a mock. + * + * Delete surfaces the backend `model_server_in_use` rejection as a dedicated, + * human-readable message (a server still referenced by a profile cannot be + * removed). + */ + +import { useCallback, useEffect, useState } from "react"; + +import type { GatewayError, LocalModelServerConfig } from "@/domain"; +import { useGateways } from "@/app/di"; + +/** What the model-server UI needs from this hook. */ +export interface ModelServersViewModel { + /** The declared local model servers. */ + servers: LocalModelServerConfig[]; + /** Last error message, or `null`. */ + error: string | null; + /** Whether a request is in flight. */ + busy: boolean; + /** Reloads the server list. */ + reload: () => Promise; + /** Creates or updates a server; returns the persisted config (or `null` on error). */ + save: (config: LocalModelServerConfig) => Promise; + /** Deletes a server by id; returns `true` on success. */ + remove: (serverId: string) => Promise; + /** Clears the current error banner. */ + clearError: () => void; +} + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +function codeOf(e: unknown): string | undefined { + if (e && typeof e === "object" && "code" in e) { + return String((e as GatewayError).code); + } + return undefined; +} + +export function useModelServers(): ModelServersViewModel { + const { modelServer } = useGateways(); + const [servers, setServers] = useState([]); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const reload = useCallback(async () => { + setBusy(true); + setError(null); + try { + setServers(await modelServer.listModelServers()); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + }, [modelServer]); + + useEffect(() => { + void reload(); + }, [reload]); + + const save = useCallback( + async (config: LocalModelServerConfig) => { + setBusy(true); + setError(null); + try { + const saved = await modelServer.saveModelServer(config); + setServers((prev) => { + const i = prev.findIndex((s) => s.id === saved.id); + if (i >= 0) { + const next = prev.slice(); + next[i] = saved; + return next; + } + return [...prev, saved]; + }); + return saved; + } catch (e) { + setError(describe(e)); + return null; + } finally { + setBusy(false); + } + }, + [modelServer], + ); + + const remove = useCallback( + async (serverId: string) => { + setBusy(true); + setError(null); + try { + await modelServer.deleteModelServer(serverId); + setServers((prev) => prev.filter((s) => s.id !== serverId)); + return true; + } catch (e) { + // A referenced server cannot be deleted — turn the stable backend code + // into an actionable message rather than a raw error string. + if (codeOf(e) === "model_server_in_use") { + setError( + "Ce serveur est encore utilisé par un profil : détache-le d'abord (choisis « aucun » ou un autre serveur) avant de le supprimer.", + ); + } else { + setError(describe(e)); + } + return false; + } finally { + setBusy(false); + } + }, + [modelServer], + ); + + const clearError = useCallback(() => setError(null), []); + + return { servers, error, busy, reload, save, remove, clearError }; +} diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index a319482..7cf7464 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -25,10 +25,12 @@ import type { LayoutList, LayoutOperation, LayoutTree, + LocalModelServerConfig, Memory, MemoryIndexEntry, MemoryLink, MemoryType, + OpenCodeConfig, EffectivePermissions, PermissionSet, PageDirection, @@ -624,6 +626,45 @@ export interface ProfileGateway { deleteProfile(profileId: string): Promise; /** Persists the batch of chosen profiles, closing the first run. */ configureProfiles(profiles: AgentProfile[]): Promise; + /** + * Mints a new OpenCode profile from the canonical `opencode-llamacpp` seed + * (F36 — several local OpenCode profiles per project, identity = id). `name` + * overrides the seed display name; `opencode` overrides the seed config (used + * by "Duplicate" to carry an existing row's endpoint). Returns the new, + * freshly-id'd profile — it is not persisted until the batch is saved. + */ + cloneOpenCodeProfileFromSeed( + input?: CloneOpenCodeProfileFromSeedInput, + ): Promise; +} + +/** Input for {@link ProfileGateway.cloneOpenCodeProfileFromSeed}. */ +export interface CloneOpenCodeProfileFromSeedInput { + /** Optional display name for the new profile. */ + name?: string; + /** Optional OpenCode config override; when omitted, the seed config is copied. */ + opencode?: OpenCodeConfig; +} + +/** + * Local model servers (F35). CRUD over the global registry of declared + * `llama.cpp` servers an OpenCode profile can bind to via + * `OpenCodeConfig.localModelServerId`. Mirrors the three backend commands + * (`list_model_servers`, `save_model_server`, `delete_model_server`). + */ +export interface ModelServerGateway { + /** Lists the declared local model servers. */ + listModelServers(): Promise; + /** + * Upserts a server config (create when `id` is a fresh client-minted UUID, + * update otherwise); returns the persisted config. + */ + saveModelServer(config: LocalModelServerConfig): Promise; + /** + * Deletes a server by id. Rejects with a `GatewayError` whose `code` is + * `model_server_in_use` when a profile still references it. + */ + deleteModelServer(serverId: string): Promise; } /** Input for {@link EmbedderGateway.saveEmbedderProfile}. */ @@ -933,6 +974,7 @@ export interface Gateways { git: GitGateway; remote: RemoteGateway; profile: ProfileGateway; + modelServer: ModelServerGateway; template: TemplateGateway; skill: SkillGateway; memory: MemoryGateway; From a244f32f3197077dd70e6f2898c82a3fe0e241a7 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 11 Jul 2026 15:50:46 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(ideai):=20versionne=20le=20store=20ti?= =?UTF-8?q?ckets/sprints/m=C3=A9moire=20du=20sprint=20Modeles=20locaux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store d'orchestration du sprint « Modeles locaux » : sprint 883534aa, tickets #35/#36 (et #32/#37–#41 du store), compteurs et index, notes mémoire de livraison (#35 CRUD/statut, #36 multi-profils, OpenCode↔llama.cpp). Runtime transitoire volontairement exclu (background-tasks/, agent scratch testllamacpp.md). Co-Authored-By: Claude Opus 4.8 --- .ideai/memory/MEMORY.md | 4 + .ideai/memory/f35-config-crud-delivered.md | 13 +++ ...-launch-status-done-config-crud-blocked.md | 16 ++++ .../f36-multi-opencode-profiles-frontend.md | 17 ++++ .../opencode-llamacpp-replaces-ollama.md | 35 ++++++++ .../sprint.md | 11 +++ .ideai/sprints/index.json | 9 ++ .ideai/tickets/32/carnet.md | 4 +- .ideai/tickets/32/issue.md | 6 +- .ideai/tickets/35/carnet.md | 6 ++ .ideai/tickets/35/issue.md | 16 ++++ .ideai/tickets/36/carnet.md | 6 ++ .ideai/tickets/36/issue.md | 16 ++++ .ideai/tickets/37/carnet.md | 6 ++ .ideai/tickets/37/issue.md | 16 ++++ .ideai/tickets/38/carnet.md | 6 ++ .ideai/tickets/38/issue.md | 16 ++++ .ideai/tickets/39/carnet.md | 6 ++ .ideai/tickets/39/issue.md | 16 ++++ .ideai/tickets/40/carnet.md | 6 ++ .ideai/tickets/40/issue.md | 16 ++++ .ideai/tickets/41/carnet.md | 6 ++ .ideai/tickets/41/issue.md | 16 ++++ .ideai/tickets/counter.json | 2 +- .ideai/tickets/index.json | 84 ++++++++++++++++++- 25 files changed, 347 insertions(+), 8 deletions(-) create mode 100644 .ideai/memory/f35-config-crud-delivered.md create mode 100644 .ideai/memory/f35-launch-status-done-config-crud-blocked.md create mode 100644 .ideai/memory/f36-multi-opencode-profiles-frontend.md create mode 100644 .ideai/memory/opencode-llamacpp-replaces-ollama.md create mode 100644 .ideai/sprints/883534aa-7fc1-4d83-a9c0-17cac4a4eea5/sprint.md create mode 100644 .ideai/tickets/35/carnet.md create mode 100644 .ideai/tickets/35/issue.md create mode 100644 .ideai/tickets/36/carnet.md create mode 100644 .ideai/tickets/36/issue.md create mode 100644 .ideai/tickets/37/carnet.md create mode 100644 .ideai/tickets/37/issue.md create mode 100644 .ideai/tickets/38/carnet.md create mode 100644 .ideai/tickets/38/issue.md create mode 100644 .ideai/tickets/39/carnet.md create mode 100644 .ideai/tickets/39/issue.md create mode 100644 .ideai/tickets/40/carnet.md create mode 100644 .ideai/tickets/40/issue.md create mode 100644 .ideai/tickets/41/carnet.md create mode 100644 .ideai/tickets/41/issue.md diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index d886c0c..fa2424d 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -73,3 +73,7 @@ - [ticket14-local-lan-openai-adapter-scoping](ticket14-local-lan-openai-adapter-scoping.md) — memory note ticket14-local-lan-openai-adapter-scoping - [ticket28-firstrun-detect-hang-scoping](ticket28-firstrun-detect-hang-scoping.md) — Cadrage hexagonal du bug bloquant first-run (#28) — panic block_on imbriqué dans detect + découplage busy/détection, contrats et point de vérité QA. - [ticket28-f1-frontend-delivered](ticket28-f1-frontend-delivered.md) — Lot F1 du ticket #28 livré sur feature/ticket28-firstrun-detect-hang — invariant busy/detectProfiles, flag detecting, timeout UI, test de non-régression. +- [opencode-llamacpp-replaces-ollama](opencode-llamacpp-replaces-ollama.md) — memory note opencode-llamacpp-replaces-ollama +- [f36-multi-opencode-profiles-frontend](f36-multi-opencode-profiles-frontend.md) — Multiplicité des profils OpenCode locaux côté UI first-run wizard — livrée, verte, sans gap backend. +- [f35-launch-status-done-config-crud-blocked](f35-launch-status-done-config-crud-blocked.md) — F35.1 UI statut de lancement du serveur local livrée+verte ; F35.2 config CRUD bloquée sur un DTO figé qui ne matche pas le wire réel + delete manquant. +- [f35-config-crud-delivered](f35-config-crud-delivered.md) — F35.2 (config serveur local + sélection dans les profils OpenCode) livrée et verte sur feature/modeles-locaux ; clôt le sprint modèles locaux F34-F36. diff --git a/.ideai/memory/f35-config-crud-delivered.md b/.ideai/memory/f35-config-crud-delivered.md new file mode 100644 index 0000000..4222f1b --- /dev/null +++ b/.ideai/memory/f35-config-crud-delivered.md @@ -0,0 +1,13 @@ +--- +name: f35-config-crud-delivered +description: F35.2 (config serveur local + sélection dans les profils OpenCode) livrée et verte sur feature/modeles-locaux ; clôt le sprint modèles locaux F34-F36. +metadata: + type: project +--- +Ticket #35 F35.2 LIVRÉ (vert) sur `feature/modeles-locaux`. Débloqué par le contrat PLAT d'Architect. + +**Contrat backend** : `list_model_servers` / `save_model_server({request:{config}})` / `delete_model_server({serverId})`. DTO plat camelCase `LocalModelServerConfig { id, kind:"llamaCpp", name, baseURL, port, modelPath?, servedModelName, binaryPath?, args, autoStart, stopPolicy }`. PIÈGE intégré : `save_model_server` exige un UUID valide dans `config.id` même à la création (backend ne génère PAS l'id serveur, seulement le model.id interne caché) → le front minte l'UUID client-side (`newModelServerId`). Erreur suppression référencée = `code:"model_server_in_use"`. + +**Livré** : `ModelServerGateway` (port + adapter Tauri `adapters/modelServer.ts` + `MockModelServerGateway` avec `markInUse`). Feature `features/model-servers/` : `useModelServers` (CRUD, traduit in-use en message FR actionnable), `ModelServersPanel` (déclarer/éditer/supprimer), `ModelServerSelect` (dropdown binding avec « None » = serveur externe + préserve un id orphelin « unknown/removed »). Dans `FirstRunWizard`, le champ texte `localModelServerId` (F36) est remplacé par `ModelServerSelect`. + +Cohérent avec F35.1 (badge corrèle via localModelServerId). tsc 0 ; suite 608/608. Sprint modèles locaux (F34 backend, F35, F36) désormais complet côté frontend. \ No newline at end of file diff --git a/.ideai/memory/f35-launch-status-done-config-crud-blocked.md b/.ideai/memory/f35-launch-status-done-config-crud-blocked.md new file mode 100644 index 0000000..7bb4f32 --- /dev/null +++ b/.ideai/memory/f35-launch-status-done-config-crud-blocked.md @@ -0,0 +1,16 @@ +--- +name: f35-launch-status-done-config-crud-blocked +description: F35.1 UI statut de lancement du serveur local livrée+verte ; F35.2 config CRUD bloquée sur un DTO figé qui ne matche pas le wire réel + delete manquant. +metadata: + type: project +--- +Ticket #35 sur `feature/modeles-locaux`. + +**F35.1 LIVRÉ (vert)** : événements `model_server_status_changed`/`agent_launch_failed` déjà exposés via `domain://event`. Consommés dans `useAgents` → `modelServerStatusByServer` (par serverId) + `launchFailureByAgent` (par agentId). Badge `ModelServerLaunchBadge` monté par ligne dans `AgentsPanel`, corrélé via `profile.opencode.localModelServerId`. Contrat réel ≠ DTO figé du ticket : `serverId` sur l'enveloppe (pas par état) ; états camelCase `notConfigured|probing|starting|ready{reused}|failed{code,message}` ; PAS de `baseURL`/`model` émis dans le statut. Je me suis aligné sur le wire réel. + +**F35.2 BLOQUÉ — 3 gaps backend remontés à Main :** +1. (bloquant) `save_model_server`/`list_model_servers` transmettent le domaine `LocalModelServerConfig` en `serde(transparent)`, forme IMBRIQUÉE : `{id, kind:"llamaCpp", name, endpoint:{baseURL,port}, model:{id,label,path?,servedName}, binary?, args, autoStart, stopPolicy}`. Le DTO figé du ticket est plat et FAUX (kind "llamacpp", baseURL/port/modelPath/servedModelName/binaryPath à plat). Pire : `model` exige `id`+`label` non-vides (LocalModelRef::new) absents du DTO figé → payload figé rejeté. Besoin arbitrage Architect : (A) couche DTO plate app-tauri qui remplit model.id/label, ou (B) MAJ du DTO figé vers la forme imbriquée + sémantique de model.id/label. +2. Pas de commande `delete_model_server`. +3. (côté moi) créer `ModelServerGateway`/port/adapter une fois le contrat tranché. + +Saisie simple de `localModelServerId` (F36) laissée en place, pas de régression. tsc propre ; suite 593/593. \ No newline at end of file diff --git a/.ideai/memory/f36-multi-opencode-profiles-frontend.md b/.ideai/memory/f36-multi-opencode-profiles-frontend.md new file mode 100644 index 0000000..64aa3b4 --- /dev/null +++ b/.ideai/memory/f36-multi-opencode-profiles-frontend.md @@ -0,0 +1,17 @@ +--- +name: f36-multi-opencode-profiles-frontend +description: Multiplicité des profils OpenCode locaux côté UI first-run wizard — livrée, verte, sans gap backend. +metadata: + type: project +--- +Ticket #36 (F36.1) livré côté frontend sur `feature/modeles-locaux`. + +**Constat clé** : aucun gap backend. Le backend supporte déjà N profils OpenCode (identité = ProfileId) via `clone_opencode_profile_from_seed`, `list_profiles`, `save_profile`, `delete_profile`, `configure_profiles`. `OpenCodeConfig` DTO expose `localModelServerId` (camelCase). + +**Ce qui manquait, purement front** : la commande clone n'était câblée dans aucun gateway, et le miroir TS `OpenCodeConfig` n'avait pas `localModelServerId`. + +**Livré** : `ProfileGateway.cloneOpenCodeProfileFromSeed` (port + adapter Tauri + mock) ; le first-run wizard (`FirstRunWizard.tsx` + `useFirstRun.ts`) gère désormais une LISTE de profils OpenCode — bouton "Add OpenCode profile" + "Duplicate" par ligne, name éditable, cases reasoning/attachment, champ localModelServerId (saisie simple). + +**Reste** : lot F35 = UI riche de config serveur local (remplacera la saisie simple de `localModelServerId`). Le wizard reste la surface de gestion de profils (`Settings ▸ Configure profiles` = `forceOpen`) ; il lit `firstRunState().referenceProfiles`, pas `list_profiles` — à vérifier au lot F35 pour l'édition de profils OpenCode déjà persistés. + +tsc propre ; 62 tests first-run/mock verts ; suite complète 581/581. \ No newline at end of file diff --git a/.ideai/memory/opencode-llamacpp-replaces-ollama.md b/.ideai/memory/opencode-llamacpp-replaces-ollama.md new file mode 100644 index 0000000..88c24db --- /dev/null +++ b/.ideai/memory/opencode-llamacpp-replaces-ollama.md @@ -0,0 +1,35 @@ +--- +name: opencode-llamacpp-replaces-ollama +description: memory note opencode-llamacpp-replaces-ollama +metadata: + type: project +--- +--- +title: OpenCode local provider — llama.cpp remplace Ollama (chantier livré) +type: decision +description: Le provider local d'OpenCode passe d'Ollama à llama.cpp (llama-server OpenAI-compatible). Profil éditable baseURL/apiKey/model. Livré sur develop (merge 2e98f1f). Clôt le diagnostic tool-calling Ollama. +--- + +# OpenCode local : llama.cpp remplace Ollama (2026-07-11) + +## Pourquoi +Le tool-calling des modèles locaux via **Ollama** ne s'est jamais déclenché nativement en live : OpenCode fuyait les appels d'outils en texte `` (émulation par prompt), jamais parsés. Diagnostic utilisateur : la faute est dans la relation **OpenCode↔Ollama**. Décision : abandonner Ollama comme provider OpenCode et passer à **llama.cpp** (`llama-server`, endpoint OpenAI-compatible). Voir historique : [[opencode-config-model-agnostic-tool-diagnostic]], [[opencode-ollama-native-tool-call-flag]], [[opencode-ollama-tool-calling-surface-overload]] (contexte Ollama, désormais superseded). + +## Contrat livré +`OpenCodeConfig` (domaine + DTO app-tauri + type frontend) : +`{ baseURL: String (requis, http/https), apiKey: Option, model: String (requis), reasoning: Option=true, attachment: Option=false }`. Sérialisation wire camelCase (`baseURL` via serde rename), `apiKey`/`reasoning`/`attachment` `skip_serializing_if=None`. + +Seed profil canonique : `opencode-ollama` → **`opencode-llamacpp`** ("OpenCode + llama.cpp"), command `opencode`, adapter `OpenCode`, defaults `baseURL=http://localhost:8080/v1`, `apiKey=sk-no-key`, `model=qwen3-coder-30b`. + +`opencode_config_json` (crates/application/src/agent/lifecycle.rs) régénère un opencode.json MINIMAL : provider `llamacpp` (`npm=@ai-sdk/openai-compatible`, `options.baseURL/apiKey` du profil, def modèle `llamacpp/` avec `tool_call:true,reasoning,attachment`), MCP `idea` inchangé, `permission bash/edit=ask`, `disabled_providers=["anthropic","openai","gemini","ollama"]`. `apiKey` omis (pas `null`) si absent. Plus aucun résidu `ollama`/préfixe `ollama/`/allow-list diagnostic. + +Frontend : wizard profil OpenCode = 3 champs éditables **Base URL / Model / API key(optionnel)** (`FirstRunWizard.tsx`, `profile.ts`, mocks). L'embedder Ollama (`ollamaDetected`, mémoire vectorielle) est un AUTRE usage, NON touché. + +## État +- Fichiers : domain/profile.rs, application/{catalogue.rs,lifecycle.rs,tests/agent_lifecycle.rs}, infrastructure/session/opencode.rs ; frontend domain/index.ts, adapters/mock/*, features/first-run/*. +- QA VERT : domain 244, application 81+64, infra 263/10 (les 10 = bind-port sandbox, identiques sur develop = non-régression), frontend 574, tsc propre. Claude/Codex non régressés. +- Git : commit `4e70631`, merge `--no-ff` `2e98f1f` sur `develop`. Base branche = `eaba05d` (profil OpenCode process-backed). WIP diagnostic Ollama capsulé dans `3cdedf2` sur `feature/opencode-glm47-flash-tool-diagnostic`. PAS de push distant. +- Tickets : #32 fermé ; #42/#43 (Ollama) retirés du store. + +## Reste ouvert +E2E live llama.cpp jamais exécuté (aucun `llama-server` joignable sur :8080 au moment QA ; `llama-server` 9859 + `opencode` 1.17.18 installés). À valider côté utilisateur : lancer llama-server, relancer l'Appointe rebuild, créer un agent profil llama.cpp, vérifier un vrai `tool_use` MCP (pas de `