feat(model-server): backend modèles locaux — serveur llama.cpp intégré (#35) et profils OpenCode locaux multiples (#36)
Sprint « Modeles locaux », couche backend (domain/application/infra/app-tauri). #35 — Serveur de modèle local intégré (llama.cpp) : - domain: model_server.rs (agrégat + statut), ports ModelServerProbe / ManagedProcess / ModelServerRuntime / ModelServerRegistry, événements model_server_status_changed et agent_launch_failed. - application: use case EnsureLocalModelServer branché sur LaunchAgent. - infrastructure: adapters HttpOpenAiCompatibleProbe, LlamaCppRuntime, LocalManagedProcess, FsModelServerRegistry. - app-tauri: DTO plat LocalModelServerConfigDto, commandes list/save/delete_model_server avec garde model_server_in_use, wiring. #36 — Profils OpenCode locaux multiples : - domain: VO LocalModelServerId, OpenCodeConfig.local_model_server_id. - application: use case CloneOpenCodeProfileFromSeed. - app-tauri: commande clone_opencode_profile_from_seed, DTO/wiring. Tests verts (exécution réelle) : domain+application OK, app-tauri dto_model_servers 3/3 et dto_profiles 10/10, infra model_server 2/2, application model_server+profile_usecases 19/19, cargo build workspace Finished. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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<ProfileDto, ErrorDto> {
|
||||
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<ModelServerConfigListDto, ErrorDto> {
|
||||
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<ModelServerConfigDto, ErrorDto> {
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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<SaveProfileOutput> for ProfileDto {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CloneOpenCodeProfileFromSeedOutput> 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<SaveProfileRequestDto> 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<String>,
|
||||
/// Optional OpenCode config override. When omitted, the seed config is copied.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode: Option<OpenCodeConfig>,
|
||||
}
|
||||
|
||||
impl From<CloneOpenCodeProfileFromSeedRequestDto> 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<FirstRunStateOutput> 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<LocalModelServerKind> for ModelServerKindDto {
|
||||
fn from(kind: LocalModelServerKind) -> Self {
|
||||
match kind {
|
||||
LocalModelServerKind::LlamaCpp => Self::LlamaCpp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ModelServerKindDto> 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<StopPolicy> for StopPolicyDto {
|
||||
fn from(policy: StopPolicy) -> Self {
|
||||
match policy {
|
||||
StopPolicy::KeepAlive => Self::KeepAlive,
|
||||
StopPolicy::StopWhenUnused => Self::StopWhenUnused,
|
||||
StopPolicy::StopOnAppExit => Self::StopOnAppExit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StopPolicyDto> 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<String>,
|
||||
/// 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<String>,
|
||||
/// Extra argv entries.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// 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<LocalModelServerConfig, ErrorDto> {
|
||||
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<ModelServerConfigDto>);
|
||||
|
||||
impl From<ListModelServersOutput> 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<SaveModelServerOutput> 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<LocalModelServerId, ErrorDto> {
|
||||
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<SaveModelServerInput, ErrorDto> {
|
||||
Ok(SaveModelServerInput {
|
||||
config: request.config.into_domain(existing)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_non_empty(raw: Option<String>) -> Option<String> {
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<ListProfiles>,
|
||||
/// Save (upsert) a profile.
|
||||
pub save_profile: Arc<SaveProfile>,
|
||||
/// Create a new OpenCode profile instance from the canonical seed.
|
||||
pub clone_opencode_profile_from_seed: Arc<CloneOpenCodeProfileFromSeed>,
|
||||
/// Delete a profile.
|
||||
pub delete_profile: Arc<DeleteProfile>,
|
||||
/// Persist the batch of chosen profiles (closes the first run).
|
||||
@ -784,6 +787,14 @@ pub struct AppState {
|
||||
pub reference_profiles: Arc<ReferenceProfiles>,
|
||||
/// Whether the first-run wizard should show + the reference catalogue.
|
||||
pub first_run_state: Arc<FirstRunState>,
|
||||
/// Ensures local llama.cpp model servers for OpenCode profiles.
|
||||
pub ensure_local_model_server: Arc<EnsureLocalModelServer>,
|
||||
/// Lists local model server configurations.
|
||||
pub list_model_servers: Arc<ListModelServers>,
|
||||
/// Saves local model server configurations.
|
||||
pub save_model_server: Arc<SaveModelServer>,
|
||||
/// Deletes local model server configurations when unused.
|
||||
pub delete_model_server: Arc<DeleteModelServer>,
|
||||
/// 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<dyn PtyPort>,
|
||||
@ -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<dyn IdGenerator>,
|
||||
));
|
||||
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<dyn domain::ports::ModelServerRegistry>,
|
||||
Arc::new(HttpOpenAiCompatibleProbe::default())
|
||||
as Arc<dyn domain::ports::ModelServerProbe>,
|
||||
Arc::new(LocalManagedProcess::new()) as Arc<dyn domain::ports::ManagedProcess>,
|
||||
Arc::new(LlamaCppRuntime::new()) as Arc<dyn domain::ports::ModelServerRuntime>,
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&events_port),
|
||||
));
|
||||
let model_server_registry_port =
|
||||
Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>;
|
||||
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<dyn Clock>,
|
||||
}) as Arc<dyn LiveStateLeanProvider>),
|
||||
}) as Arc<dyn LiveStateLeanProvider>)
|
||||
.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<dyn Clock>,
|
||||
}) as Arc<dyn LiveStateLeanProvider>)
|
||||
.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,
|
||||
|
||||
111
crates/app-tauri/tests/dto_model_servers.rs
Normal file
111
crates/app-tauri/tests/dto_model_servers.rs
Normal file
@ -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");
|
||||
}
|
||||
@ -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": [] });
|
||||
|
||||
Reference in New Issue
Block a user