merge feature/multi-profiles-codex-clarification-toast-notices dans develop
Résout le conflit d'imports/helpers de tests dans crates/application/tests/model_server.rs par union des deux côtés (imports application/domain + helpers aid/nid/sess). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -41,22 +41,23 @@ use crate::dto::{
|
||||
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachLiveAgentRequestDto,
|
||||
AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto,
|
||||
ChangeAgentProfileRequestDto, 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, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
||||
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
|
||||
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
|
||||
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
|
||||
ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||
CloneProfileFromSeedRequestDto, 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, OpenCodeProviderListDto,
|
||||
OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto,
|
||||
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
|
||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
|
||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
|
||||
@ -1188,6 +1189,22 @@ pub async fn list_opencode_providers(
|
||||
Ok(state.list_opencode_providers.execute().into())
|
||||
}
|
||||
|
||||
/// `list_claude_models` — enriched Claude model catalogue.
|
||||
#[tauri::command]
|
||||
pub async fn list_claude_models(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProfileModelCatalogDto, ErrorDto> {
|
||||
Ok(state.list_claude_models.execute().await.into())
|
||||
}
|
||||
|
||||
/// `list_codex_models` — enriched Codex model catalogue.
|
||||
#[tauri::command]
|
||||
pub async fn list_codex_models(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProfileModelCatalogDto, ErrorDto> {
|
||||
Ok(state.list_codex_models.execute().await.into())
|
||||
}
|
||||
|
||||
/// `save_opencode_provider_profile` — create or replace an OpenCode profile
|
||||
/// backed by a cloud provider (ticket #92, lot B3). The literal API key is
|
||||
/// sealed into the `SecretStore`, never persisted in `profiles.json`.
|
||||
@ -1227,6 +1244,25 @@ pub async fn clone_opencode_profile_from_seed(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `clone_profile_from_seed` — create a new profile instance from a
|
||||
/// persisted/reference seed, with optional name/model overrides.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an [`ErrorDto`] (`NOT_FOUND` for an unknown seed, `STORE` on profiles
|
||||
/// I/O failure, `INVALID` for a blank requested name/model).
|
||||
#[tauri::command]
|
||||
pub async fn clone_profile_from_seed(
|
||||
request: CloneProfileFromSeedRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProfileDto, ErrorDto> {
|
||||
state
|
||||
.clone_profile_from_seed
|
||||
.execute(request.into())
|
||||
.await
|
||||
.map(ProfileDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `delete_profile` — delete a profile by id.
|
||||
///
|
||||
/// # Errors
|
||||
@ -1303,6 +1339,7 @@ pub async fn save_model_server(
|
||||
.map_err(ErrorDto::from)?
|
||||
.servers
|
||||
.into_iter()
|
||||
.map(|item| item.config)
|
||||
.find(|config| config.id == server_id);
|
||||
let input = save_model_server_input(request, existing.as_ref())?;
|
||||
state
|
||||
@ -1352,6 +1389,26 @@ pub async fn delete_model_server(
|
||||
.map_err(model_server_command_error)
|
||||
}
|
||||
|
||||
/// `delete_model_artifact` — delete a managed downloaded model artifact while
|
||||
/// keeping the local model-server config.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns `invalid` for non-managed `localPath` sources, `model_server_in_use`
|
||||
/// when a download or live agent blocks deletion, and model-server errors for
|
||||
/// cache I/O failures.
|
||||
#[tauri::command]
|
||||
pub async fn delete_model_artifact(
|
||||
server_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let server_id = parse_model_server_id(&server_id)?;
|
||||
state
|
||||
.delete_model_artifact
|
||||
.execute(application::DeleteModelArtifactInput { 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 },
|
||||
|
||||
@ -255,6 +255,9 @@ pub fn run() {
|
||||
commands::save_profile,
|
||||
commands::save_opencode_provider_profile,
|
||||
commands::list_opencode_providers,
|
||||
commands::list_claude_models,
|
||||
commands::list_codex_models,
|
||||
commands::clone_profile_from_seed,
|
||||
commands::clone_opencode_profile_from_seed,
|
||||
commands::delete_profile,
|
||||
commands::configure_profiles,
|
||||
@ -262,6 +265,7 @@ pub fn run() {
|
||||
commands::save_model_server,
|
||||
commands::preview_model_server_command,
|
||||
commands::delete_model_server,
|
||||
commands::delete_model_artifact,
|
||||
commands::list_embedder_profiles,
|
||||
commands::save_embedder_profile,
|
||||
commands::delete_embedder_profile,
|
||||
|
||||
@ -132,6 +132,7 @@ fn model_server_dto_preserves_existing_internal_model_id_on_upsert() {
|
||||
auto_start: true,
|
||||
stop_policy: StopPolicyDto::StopOnAppExit,
|
||||
warmup_deadline_secs: Some(900),
|
||||
artifact: Default::default(),
|
||||
};
|
||||
|
||||
let config = dto.into_domain(Some(&existing)).unwrap();
|
||||
|
||||
@ -4,15 +4,17 @@
|
||||
|
||||
use app_tauri_lib::dto::{
|
||||
parse_delete_profile, parse_profile_id, CloneOpenCodeProfileFromSeedRequestDto,
|
||||
ConfigureProfilesRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||
FirstRunStateDto, ProfileListDto, SaveProfileRequestDto,
|
||||
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, DetectProfilesRequestDto,
|
||||
DetectProfilesResponseDto, FirstRunStateDto, ProfileListDto, ProfileModelCatalogDto,
|
||||
SaveProfileRequestDto,
|
||||
};
|
||||
use application::{
|
||||
CloneOpenCodeProfileFromSeedInput, ConfigureProfilesInput, DetectProfilesInput,
|
||||
DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability, SaveProfileInput,
|
||||
CloneOpenCodeProfileFromSeedInput, CloneProfileFromSeedInput, ConfigureProfilesInput,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability,
|
||||
SaveProfileInput,
|
||||
};
|
||||
use domain::ids::{LocalModelServerId, ProfileId};
|
||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig};
|
||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -121,6 +123,50 @@ fn clone_opencode_profile_from_seed_request_deserialises_camelcase_config() {
|
||||
assert_eq!(opencode.local_model_server_id, Some(server_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_profile_from_seed_request_deserialises_camelcase_overrides() {
|
||||
let seed_id = Uuid::from_u128(42);
|
||||
let raw = json!({
|
||||
"seedProfileId": seed_id.to_string(),
|
||||
"name": "Codex GPT-5",
|
||||
"model": "gpt-5-codex"
|
||||
});
|
||||
|
||||
let dto: CloneProfileFromSeedRequestDto = serde_json::from_value(raw).unwrap();
|
||||
let input: CloneProfileFromSeedInput = dto.into();
|
||||
assert_eq!(input.seed_profile_id, ProfileId::from_uuid(seed_id));
|
||||
assert_eq!(input.name.as_deref(), Some("Codex GPT-5"));
|
||||
assert_eq!(input.model.as_deref(), Some("gpt-5-codex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_model_catalogue_dto_serialises_searchable_camelcase_entries() {
|
||||
let dto = ProfileModelCatalogDto {
|
||||
models: vec![app_tauri_lib::dto::ProfileModelCatalogEntryDto {
|
||||
adapter: StructuredAdapter::Codex,
|
||||
model_id: "gpt-5-codex".to_owned(),
|
||||
display_name: "GPT-5 Codex".to_owned(),
|
||||
aliases: vec!["codex".to_owned()],
|
||||
recommended: true,
|
||||
compatibility: domain::ModelCompatibility::Compatible,
|
||||
source: domain::ModelCatalogSource::Catalogue,
|
||||
}],
|
||||
cli_version: Some("0.45.1".to_owned()),
|
||||
warnings: vec!["provider unavailable".to_owned()],
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(value["cliVersion"], "0.45.1");
|
||||
assert_eq!(value["warnings"], json!(["provider unavailable"]));
|
||||
assert_eq!(value["models"][0]["adapter"], "codex");
|
||||
assert_eq!(value["models"][0]["modelId"], "gpt-5-codex");
|
||||
assert_eq!(value["models"][0]["displayName"], "GPT-5 Codex");
|
||||
assert_eq!(value["models"][0]["aliases"], json!(["codex"]));
|
||||
assert_eq!(value["models"][0]["recommended"], true);
|
||||
assert_eq!(value["models"][0]["compatibility"], "compatible");
|
||||
assert_eq!(value["models"][0]["source"], "catalogue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_config_dto_omits_local_model_server_id_when_none() {
|
||||
let config = OpenCodeConfig::new(
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
mod catalogue;
|
||||
mod inspect;
|
||||
mod lifecycle;
|
||||
mod model_catalogue;
|
||||
mod provider_catalogue;
|
||||
mod resume;
|
||||
mod session_limit;
|
||||
@ -39,6 +40,10 @@ pub use lifecycle::{
|
||||
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
|
||||
};
|
||||
pub use model_catalogue::{
|
||||
claude_model_catalogue, codex_model_catalogue, ListClaudeModels, ListClaudeModelsOutput,
|
||||
ListCodexModels, ListCodexModelsOutput, ProfileModelCatalogEntry,
|
||||
};
|
||||
pub use provider_catalogue::{
|
||||
opencode_models_cache_path, opencode_provider_catalogue, ListOpenCodeProviders,
|
||||
ListOpenCodeProvidersOutput, OpenCodeProviderCatalogEntry,
|
||||
@ -48,10 +53,11 @@ pub use resume::{
|
||||
};
|
||||
pub use usecases::{
|
||||
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles,
|
||||
ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput,
|
||||
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||
CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput,
|
||||
CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput,
|
||||
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput,
|
||||
FirstRunState, FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability,
|
||||
ReferenceProfiles, ReferenceProfilesOutput, SaveOpenCodeProviderProfile,
|
||||
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile,
|
||||
SaveProfileInput, SaveProfileOutput,
|
||||
};
|
||||
|
||||
468
crates/application/src/agent/model_catalogue.rs
Normal file
468
crates/application/src/agent/model_catalogue.rs
Normal file
@ -0,0 +1,468 @@
|
||||
//! Curated and best-effort model catalogues for structured Claude/Codex profiles.
|
||||
//!
|
||||
//! The CLIs do not expose a stable machine-readable model catalogue and must not
|
||||
//! be asked to enumerate models. The only local probe allowed here is
|
||||
//! `<cli> --version`; provider APIs are optional and failures degrade to the
|
||||
//! static seed plus warnings.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::model_catalogue::{evaluate_compatibility, CliVersion};
|
||||
use domain::ports::{CliVersionReader, CompatibilityMatrixSource, ProviderModelCatalogue};
|
||||
use domain::profile::StructuredAdapter;
|
||||
use domain::{ModelCatalogSource, ModelCompatibility};
|
||||
|
||||
/// One searchable model entry for a structured profile adapter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProfileModelCatalogEntry {
|
||||
/// Structured adapter this model belongs to.
|
||||
pub adapter: StructuredAdapter,
|
||||
/// Exact model identifier to persist on [`domain::profile::AgentProfile::model`].
|
||||
pub model_id: String,
|
||||
/// Human-readable label for picker display.
|
||||
pub display_name: String,
|
||||
/// Extra search tokens useful to the frontend.
|
||||
pub aliases: Vec<String>,
|
||||
/// Whether this entry is the conservative default suggestion.
|
||||
pub recommended: bool,
|
||||
/// Compatibility state against the locally detected CLI version.
|
||||
pub compatibility: ModelCompatibility,
|
||||
/// Source that contributed the model entry.
|
||||
pub source: ModelCatalogSource,
|
||||
}
|
||||
|
||||
fn entry(
|
||||
adapter: StructuredAdapter,
|
||||
model_id: &str,
|
||||
display_name: &str,
|
||||
aliases: &[&str],
|
||||
recommended: bool,
|
||||
) -> ProfileModelCatalogEntry {
|
||||
ProfileModelCatalogEntry {
|
||||
adapter,
|
||||
model_id: model_id.to_owned(),
|
||||
display_name: display_name.to_owned(),
|
||||
aliases: aliases.iter().map(|alias| (*alias).to_owned()).collect(),
|
||||
recommended,
|
||||
compatibility: ModelCompatibility::Unknown,
|
||||
source: ModelCatalogSource::Catalogue,
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_entry(adapter: StructuredAdapter, model_id: String) -> ProfileModelCatalogEntry {
|
||||
ProfileModelCatalogEntry {
|
||||
adapter,
|
||||
display_name: model_id.clone(),
|
||||
model_id,
|
||||
aliases: Vec::new(),
|
||||
recommended: false,
|
||||
compatibility: ModelCompatibility::Unknown,
|
||||
source: ModelCatalogSource::Provider,
|
||||
}
|
||||
}
|
||||
|
||||
/// Static Claude Code model catalogue.
|
||||
#[must_use]
|
||||
pub fn claude_model_catalogue() -> Vec<ProfileModelCatalogEntry> {
|
||||
vec![
|
||||
entry(
|
||||
StructuredAdapter::Claude,
|
||||
"claude-sonnet-5",
|
||||
"Claude Sonnet 5",
|
||||
&["sonnet"],
|
||||
true,
|
||||
),
|
||||
entry(
|
||||
StructuredAdapter::Claude,
|
||||
"claude-opus-4-8",
|
||||
"Claude Opus 4.8",
|
||||
&["opus"],
|
||||
false,
|
||||
),
|
||||
entry(
|
||||
StructuredAdapter::Claude,
|
||||
"claude-haiku-4-5-20251001",
|
||||
"Claude Haiku 4.5",
|
||||
&["haiku"],
|
||||
false,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Static OpenAI Codex CLI model catalogue.
|
||||
#[must_use]
|
||||
pub fn codex_model_catalogue() -> Vec<ProfileModelCatalogEntry> {
|
||||
vec![
|
||||
entry(
|
||||
StructuredAdapter::Codex,
|
||||
"gpt-5-codex",
|
||||
"GPT-5 Codex",
|
||||
&["codex"],
|
||||
true,
|
||||
),
|
||||
entry(
|
||||
StructuredAdapter::Codex,
|
||||
"gpt-5",
|
||||
"GPT-5",
|
||||
&["general"],
|
||||
false,
|
||||
),
|
||||
entry(
|
||||
StructuredAdapter::Codex,
|
||||
"gpt-5-mini",
|
||||
"GPT-5 mini",
|
||||
&["mini", "fast"],
|
||||
false,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Output of structured model-catalogue resolution.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListModelsOutput {
|
||||
/// The catalogue entries.
|
||||
pub models: Vec<ProfileModelCatalogEntry>,
|
||||
/// Best-effort local CLI version.
|
||||
pub cli_version: Option<CliVersion>,
|
||||
/// Non-fatal fallback/degradation warnings.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Output of [`ListClaudeModels::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListClaudeModelsOutput {
|
||||
/// The catalogue entries.
|
||||
pub models: Vec<ProfileModelCatalogEntry>,
|
||||
/// Best-effort local CLI version.
|
||||
pub cli_version: Option<CliVersion>,
|
||||
/// Non-fatal fallback/degradation warnings.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<ListModelsOutput> for ListClaudeModelsOutput {
|
||||
fn from(out: ListModelsOutput) -> Self {
|
||||
Self {
|
||||
models: out.models,
|
||||
cli_version: out.cli_version,
|
||||
warnings: out.warnings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Output of [`ListCodexModels::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListCodexModelsOutput {
|
||||
/// The catalogue entries.
|
||||
pub models: Vec<ProfileModelCatalogEntry>,
|
||||
/// Best-effort local CLI version.
|
||||
pub cli_version: Option<CliVersion>,
|
||||
/// Non-fatal fallback/degradation warnings.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<ListModelsOutput> for ListCodexModelsOutput {
|
||||
fn from(out: ListModelsOutput) -> Self {
|
||||
Self {
|
||||
models: out.models,
|
||||
cli_version: out.cli_version,
|
||||
warnings: out.warnings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Use case resolving an enriched structured model catalogue.
|
||||
pub struct ResolveModelCatalogue {
|
||||
cli_versions: Arc<dyn CliVersionReader>,
|
||||
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
|
||||
matrix_source: Arc<dyn CompatibilityMatrixSource>,
|
||||
}
|
||||
|
||||
impl ResolveModelCatalogue {
|
||||
/// Builds the use case from hexagonal ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
cli_versions: Arc<dyn CliVersionReader>,
|
||||
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
|
||||
matrix_source: Arc<dyn CompatibilityMatrixSource>,
|
||||
) -> Self {
|
||||
Self {
|
||||
cli_versions,
|
||||
provider_catalogue,
|
||||
matrix_source,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves models for one structured adapter. Infallible by design.
|
||||
pub async fn execute(&self, adapter: StructuredAdapter) -> ListModelsOutput {
|
||||
let mut warnings = Vec::new();
|
||||
let (matrix, matrix_warnings) = self.matrix_source.compatibility_matrix();
|
||||
warnings.extend(matrix_warnings);
|
||||
|
||||
let cli_version = match self.cli_versions.read_cli_version(adapter).await {
|
||||
Ok(version) => version,
|
||||
Err(warning) => {
|
||||
warnings.push(warning);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let mut models = match adapter {
|
||||
StructuredAdapter::Claude => claude_model_catalogue(),
|
||||
StructuredAdapter::Codex => codex_model_catalogue(),
|
||||
StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => Vec::new(),
|
||||
};
|
||||
|
||||
match self.provider_catalogue.list_provider_models(adapter).await {
|
||||
Ok(provider_models) => {
|
||||
let existing = models
|
||||
.iter()
|
||||
.map(|model| model.model_id.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
models.extend(
|
||||
provider_models
|
||||
.into_iter()
|
||||
.filter(|model_id| !existing.contains(model_id))
|
||||
.map(|model_id| provider_entry(adapter, model_id)),
|
||||
);
|
||||
}
|
||||
Err(warning) => warnings.push(warning),
|
||||
}
|
||||
|
||||
for model in &mut models {
|
||||
model.compatibility =
|
||||
evaluate_compatibility(&matrix, adapter, &model.model_id, cli_version.as_ref());
|
||||
}
|
||||
|
||||
models.sort_by(|a, b| {
|
||||
b.recommended
|
||||
.cmp(&a.recommended)
|
||||
.then_with(|| a.display_name.cmp(&b.display_name))
|
||||
.then_with(|| a.model_id.cmp(&b.model_id))
|
||||
});
|
||||
|
||||
ListModelsOutput {
|
||||
models,
|
||||
cli_version,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Use case exposing the Claude model catalogue.
|
||||
pub struct ListClaudeModels {
|
||||
resolver: ResolveModelCatalogue,
|
||||
}
|
||||
|
||||
impl ListClaudeModels {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
cli_versions: Arc<dyn CliVersionReader>,
|
||||
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
|
||||
matrix_source: Arc<dyn CompatibilityMatrixSource>,
|
||||
) -> Self {
|
||||
Self {
|
||||
resolver: ResolveModelCatalogue::new(cli_versions, provider_catalogue, matrix_source),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists Claude models. Infallible.
|
||||
pub async fn execute(&self) -> ListClaudeModelsOutput {
|
||||
self.resolver
|
||||
.execute(StructuredAdapter::Claude)
|
||||
.await
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Use case exposing the Codex model catalogue.
|
||||
pub struct ListCodexModels {
|
||||
resolver: ResolveModelCatalogue,
|
||||
}
|
||||
|
||||
impl ListCodexModels {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
cli_versions: Arc<dyn CliVersionReader>,
|
||||
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
|
||||
matrix_source: Arc<dyn CompatibilityMatrixSource>,
|
||||
) -> Self {
|
||||
Self {
|
||||
resolver: ResolveModelCatalogue::new(cli_versions, provider_catalogue, matrix_source),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists Codex models. Infallible.
|
||||
pub async fn execute(&self) -> ListCodexModelsOutput {
|
||||
self.resolver.execute(StructuredAdapter::Codex).await.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use domain::{CliVersion, CompatibilityMatrix};
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct FakeCliVersionReader(Option<Result<Option<CliVersion>, String>>);
|
||||
|
||||
#[async_trait]
|
||||
impl CliVersionReader for FakeCliVersionReader {
|
||||
async fn read_cli_version(
|
||||
&self,
|
||||
_adapter: StructuredAdapter,
|
||||
) -> Result<Option<CliVersion>, String> {
|
||||
self.0
|
||||
.clone()
|
||||
.unwrap_or_else(|| Ok(Some(CliVersion::parse("1.0.0").unwrap())))
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeProvider(Vec<String>, Option<String>);
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderModelCatalogue for FakeProvider {
|
||||
async fn list_provider_models(
|
||||
&self,
|
||||
_adapter: StructuredAdapter,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if let Some(warning) = &self.1 {
|
||||
Err(warning.clone())
|
||||
} else {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeMatrixSource(CompatibilityMatrix, Vec<String>);
|
||||
|
||||
impl CompatibilityMatrixSource for FakeMatrixSource {
|
||||
fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec<String>) {
|
||||
(self.0.clone(), self.1.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn resolver(
|
||||
version: Option<Result<Option<CliVersion>, String>>,
|
||||
provider: Vec<String>,
|
||||
provider_warning: Option<String>,
|
||||
) -> ResolveModelCatalogue {
|
||||
ResolveModelCatalogue::new(
|
||||
Arc::new(FakeCliVersionReader(version)),
|
||||
Arc::new(FakeProvider(provider, provider_warning)),
|
||||
Arc::new(FakeMatrixSource(
|
||||
CompatibilityMatrix {
|
||||
version: 1,
|
||||
claude: HashMap::from([
|
||||
("claude-sonnet-5".to_owned(), "1.0.0".to_owned()),
|
||||
("claude-opus-4-8".to_owned(), "2.0.0".to_owned()),
|
||||
]),
|
||||
codex: HashMap::from([("gpt-5-codex".to_owned(), "1.0.0".to_owned())]),
|
||||
},
|
||||
vec![],
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_catalogues_are_non_empty_searchable_and_have_one_default() {
|
||||
for (adapter, models) in [
|
||||
(StructuredAdapter::Claude, claude_model_catalogue()),
|
||||
(StructuredAdapter::Codex, codex_model_catalogue()),
|
||||
] {
|
||||
assert!(!models.is_empty());
|
||||
assert_eq!(
|
||||
models.iter().filter(|model| model.recommended).count(),
|
||||
1,
|
||||
"{adapter:?} should expose one default suggestion"
|
||||
);
|
||||
for model in models {
|
||||
assert_eq!(model.adapter, adapter);
|
||||
assert!(!model.model_id.trim().is_empty());
|
||||
assert!(!model.display_name.trim().is_empty());
|
||||
assert_eq!(model.compatibility, ModelCompatibility::Unknown);
|
||||
assert_eq!(model.source, ModelCatalogSource::Catalogue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_enriches_static_catalogue_with_cli_compatibility() {
|
||||
let out = resolver(
|
||||
Some(Ok(Some(CliVersion::parse("1.0.0").unwrap()))),
|
||||
vec![],
|
||||
None,
|
||||
)
|
||||
.execute(StructuredAdapter::Claude)
|
||||
.await;
|
||||
|
||||
let sonnet = out
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.model_id == "claude-sonnet-5")
|
||||
.unwrap();
|
||||
let opus = out
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.model_id == "claude-opus-4-8")
|
||||
.unwrap();
|
||||
assert_eq!(sonnet.compatibility, ModelCompatibility::Compatible);
|
||||
assert_eq!(opus.compatibility, ModelCompatibility::LikelyTooRecent);
|
||||
assert_eq!(out.cli_version.unwrap().raw, "1.0.0");
|
||||
assert!(out.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_keeps_provider_and_cli_failures_non_blocking() {
|
||||
let out = resolver(
|
||||
Some(Err("codex version unavailable".to_owned())),
|
||||
vec![],
|
||||
Some("provider unavailable".to_owned()),
|
||||
)
|
||||
.execute(StructuredAdapter::Codex)
|
||||
.await;
|
||||
|
||||
assert!(!out.models.is_empty());
|
||||
assert_eq!(out.cli_version, None);
|
||||
assert_eq!(
|
||||
out.warnings,
|
||||
vec![
|
||||
"codex version unavailable".to_owned(),
|
||||
"provider unavailable".to_owned()
|
||||
]
|
||||
);
|
||||
assert!(out
|
||||
.models
|
||||
.iter()
|
||||
.all(|model| model.compatibility == ModelCompatibility::Unknown));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_adds_provider_only_models_without_duplicate_seed_entries() {
|
||||
let out = resolver(
|
||||
None,
|
||||
vec!["gpt-5-codex".to_owned(), "gpt-5-provider".to_owned()],
|
||||
None,
|
||||
)
|
||||
.execute(StructuredAdapter::Codex)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
out.models
|
||||
.iter()
|
||||
.filter(|model| model.model_id == "gpt-5-codex")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let provider = out
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.model_id == "gpt-5-provider")
|
||||
.unwrap();
|
||||
assert_eq!(provider.source, ModelCatalogSource::Provider);
|
||||
assert_eq!(provider.compatibility, ModelCompatibility::Unknown);
|
||||
}
|
||||
}
|
||||
@ -146,6 +146,92 @@ pub struct SaveProfileOutput {
|
||||
pub profile: AgentProfile,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CloneProfileFromSeed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`CloneProfileFromSeed::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CloneProfileFromSeedInput {
|
||||
/// Id of the persisted or reference profile to clone.
|
||||
pub seed_profile_id: ProfileId,
|
||||
/// Optional display name for the cloned profile. When absent, a copy label is
|
||||
/// derived from the seed name.
|
||||
pub name: Option<String>,
|
||||
/// Optional model override. When absent, the seed model is copied as-is.
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
/// Output of [`CloneProfileFromSeed::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CloneProfileFromSeedOutput {
|
||||
/// The newly persisted profile.
|
||||
pub profile: AgentProfile,
|
||||
}
|
||||
|
||||
/// Creates a new profile instance from an existing persisted/reference seed.
|
||||
///
|
||||
/// Persisted profiles are preferred over reference seeds so user edits to the
|
||||
/// seed are preserved. The clone always receives a fresh [`ProfileId`] from the
|
||||
/// backend [`IdGenerator`]; callers can override the display name and model
|
||||
/// without minting ids client-side.
|
||||
pub struct CloneProfileFromSeed {
|
||||
store: Arc<dyn ProfileStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
}
|
||||
|
||||
impl CloneProfileFromSeed {
|
||||
/// Builds the use case from the profile store and id generator ports.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProfileStore>, ids: Arc<dyn IdGenerator>) -> Self {
|
||||
Self { store, ids }
|
||||
}
|
||||
|
||||
/// Clones the requested seed into a new persisted profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::NotFound`] if no persisted/reference profile has the seed id,
|
||||
/// [`AppError::Invalid`] if `name` or `model` is blank, [`AppError::Store`]
|
||||
/// on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: CloneProfileFromSeedInput,
|
||||
) -> Result<CloneProfileFromSeedOutput, AppError> {
|
||||
let existing = self.store.list().await?;
|
||||
let seed = existing
|
||||
.iter()
|
||||
.find(|profile| profile.id == input.seed_profile_id)
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
reference_profiles()
|
||||
.into_iter()
|
||||
.find(|profile| profile.id == input.seed_profile_id)
|
||||
})
|
||||
.ok_or(AppError::NotFound("profile seed not found".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(model) = input.model {
|
||||
if model.trim().is_empty() {
|
||||
return Err(AppError::Invalid("profile.model must not be empty".into()));
|
||||
}
|
||||
profile.model = Some(model);
|
||||
}
|
||||
|
||||
self.store.save(&profile).await?;
|
||||
Ok(CloneProfileFromSeedOutput { profile })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CloneOpenCodeProfileFromSeed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -40,21 +40,23 @@ pub mod window;
|
||||
pub mod workstate;
|
||||
|
||||
pub use agent::{
|
||||
drain_reply_stream_with_readiness, drain_with_readiness,
|
||||
drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id,
|
||||
reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer,
|
||||
AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
||||
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, ListOpenCodeProviders, ListOpenCodeProvidersOutput, ListProfiles,
|
||||
ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput,
|
||||
LiveStateLeanProvider, McpRuntime, OpenCodeProviderCatalogEntry, PermissionProjectorRegistry,
|
||||
ProfileAvailability, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
|
||||
claude_model_catalogue, codex_model_catalogue, drain_reply_stream_with_readiness,
|
||||
drain_with_readiness, drain_with_readiness_and_announcements, drain_with_readiness_outcome,
|
||||
reference_profile_id, reference_profiles, selectable_reference_profiles, send_blocking,
|
||||
AgentResumer, AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput,
|
||||
ChangeAgentProfileOutput, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||
CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput,
|
||||
CloneProfileFromSeedOutput, 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, ListClaudeModels,
|
||||
ListClaudeModelsOutput, ListCodexModels, ListCodexModelsOutput, ListOpenCodeProviders,
|
||||
ListOpenCodeProvidersOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
||||
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
|
||||
OpenCodeProviderCatalogEntry, PermissionProjectorRegistry, ProfileAvailability,
|
||||
ProfileModelCatalogEntry, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
|
||||
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent,
|
||||
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||
@ -125,10 +127,12 @@ pub use memory::{
|
||||
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||
};
|
||||
pub use model_server::{
|
||||
model_server_error_code, DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer,
|
||||
EnsureLocalModelServerInput, EnsureLocalModelServerOutput, ListModelServers,
|
||||
ListModelServersOutput, ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer,
|
||||
SaveModelServerInput, SaveModelServerOutput,
|
||||
model_server_error_code, DeleteModelArtifact, DeleteModelArtifactInput, DeleteModelServer,
|
||||
DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput,
|
||||
EnsureLocalModelServerOutput, ListModelServers, ListModelServersOutput,
|
||||
ModelArtifactDownloadTracker, ModelArtifactView, ModelServerListItem,
|
||||
ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer, SaveModelServerInput,
|
||||
SaveModelServerOutput,
|
||||
};
|
||||
pub use orchestrator::{
|
||||
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,
|
||||
|
||||
@ -11,33 +11,101 @@ use domain::model_server::{
|
||||
ModelSource,
|
||||
};
|
||||
use domain::ports::{
|
||||
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel,
|
||||
ModelArtifactDownloader, ModelArtifactProgress, ModelServerError, ModelServerProbe,
|
||||
ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore, RemotePath,
|
||||
AgentContextStore, EventBus, FileSystem, ManagedProcess, ManagedProcessHandle,
|
||||
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactState,
|
||||
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
|
||||
ProfileStore, ProjectStore, RemotePath,
|
||||
};
|
||||
use domain::{LocalModelServerId, ProjectId, StopPolicy};
|
||||
use tokio::sync::{Mutex as AsyncMutex, Notify};
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::terminal::LiveAgentRegistry;
|
||||
|
||||
/// Artifact cache state exposed by model-server list use cases.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ModelArtifactView {
|
||||
/// The configured source is not managed by IdeA's downloader.
|
||||
NotManaged,
|
||||
/// The configured source is managed but not present in cache.
|
||||
Missing,
|
||||
/// A download/prepare operation is currently running for this server.
|
||||
Downloading,
|
||||
/// The configured source is present in cache.
|
||||
Downloaded {
|
||||
/// Local artifact path used by llama.cpp.
|
||||
path: String,
|
||||
/// Total on-disk size when known.
|
||||
size_bytes: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<ModelArtifactState> for ModelArtifactView {
|
||||
fn from(state: ModelArtifactState) -> Self {
|
||||
match state {
|
||||
ModelArtifactState::NotManaged => Self::NotManaged,
|
||||
ModelArtifactState::Missing => Self::Missing,
|
||||
ModelArtifactState::Downloaded { path, size_bytes } => Self::Downloaded {
|
||||
path: path.as_str().to_owned(),
|
||||
size_bytes,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A configured local model server plus derived artifact state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ModelServerListItem {
|
||||
/// Persisted local model-server config.
|
||||
pub config: LocalModelServerConfig,
|
||||
/// Derived artifact cache state.
|
||||
pub artifact: ModelArtifactView,
|
||||
}
|
||||
|
||||
/// Output of [`ListModelServers::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListModelServersOutput {
|
||||
/// Persisted local model-server configs.
|
||||
pub servers: Vec<LocalModelServerConfig>,
|
||||
/// Persisted local model-server configs enriched with artifact state.
|
||||
pub servers: Vec<ModelServerListItem>,
|
||||
}
|
||||
|
||||
/// Lists local model-server configurations.
|
||||
pub struct ListModelServers {
|
||||
registry: Arc<dyn ModelServerRegistry>,
|
||||
downloader: Option<Arc<dyn ModelArtifactDownloader>>,
|
||||
downloads: Option<Arc<dyn ModelArtifactDownloadTracker>>,
|
||||
}
|
||||
|
||||
impl ListModelServers {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(registry: Arc<dyn ModelServerRegistry>) -> Self {
|
||||
Self { registry }
|
||||
Self {
|
||||
registry,
|
||||
downloader: None,
|
||||
downloads: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enables artifact state enrichment for Hugging Face-backed servers.
|
||||
#[must_use]
|
||||
pub fn with_model_artifact_downloader(
|
||||
mut self,
|
||||
downloader: Arc<dyn ModelArtifactDownloader>,
|
||||
) -> Self {
|
||||
self.downloader = Some(downloader);
|
||||
self
|
||||
}
|
||||
|
||||
/// Enables in-flight download state enrichment.
|
||||
#[must_use]
|
||||
pub fn with_download_tracker(
|
||||
mut self,
|
||||
downloads: Arc<dyn ModelArtifactDownloadTracker>,
|
||||
) -> Self {
|
||||
self.downloads = Some(downloads);
|
||||
self
|
||||
}
|
||||
|
||||
/// Lists configs.
|
||||
@ -45,10 +113,44 @@ impl ListModelServers {
|
||||
/// # Errors
|
||||
/// [`AppError::ModelServer`] on registry failure.
|
||||
pub async fn execute(&self) -> Result<ListModelServersOutput, AppError> {
|
||||
Ok(ListModelServersOutput {
|
||||
servers: self.registry.list().await?,
|
||||
})
|
||||
let configs = self.registry.list().await?;
|
||||
let mut servers = Vec::with_capacity(configs.len());
|
||||
for config in configs {
|
||||
let artifact = self.artifact_view(&config).await?;
|
||||
servers.push(ModelServerListItem { config, artifact });
|
||||
}
|
||||
Ok(ListModelServersOutput { servers })
|
||||
}
|
||||
|
||||
async fn artifact_view(
|
||||
&self,
|
||||
config: &LocalModelServerConfig,
|
||||
) -> Result<ModelArtifactView, AppError> {
|
||||
if self
|
||||
.downloads
|
||||
.as_ref()
|
||||
.is_some_and(|downloads| downloads.is_model_artifact_download_in_progress(config.id))
|
||||
{
|
||||
return Ok(ModelArtifactView::Downloading);
|
||||
}
|
||||
let Some(ModelSource::HuggingFace { repo }) = config.model.source.as_ref() else {
|
||||
return Ok(ModelArtifactView::NotManaged);
|
||||
};
|
||||
let Some(downloader) = self.downloader.as_ref() else {
|
||||
return Ok(ModelArtifactView::Missing);
|
||||
};
|
||||
downloader
|
||||
.hf_model_state(repo)
|
||||
.await
|
||||
.map(ModelArtifactView::from)
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only in-flight download state shared by list/delete use cases.
|
||||
pub trait ModelArtifactDownloadTracker: Send + Sync {
|
||||
/// Whether the model artifact for `server_id` is currently being resolved/downloaded.
|
||||
fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool;
|
||||
}
|
||||
|
||||
/// Input for [`SaveModelServer::execute`].
|
||||
@ -132,6 +234,154 @@ impl DeleteModelServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`DeleteModelArtifact::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteModelArtifactInput {
|
||||
/// Config id whose managed artifact cache should be deleted.
|
||||
pub server_id: LocalModelServerId,
|
||||
}
|
||||
|
||||
/// Deletes a downloaded model artifact without deleting the server config.
|
||||
pub struct DeleteModelArtifact {
|
||||
registry: Arc<dyn ModelServerRegistry>,
|
||||
probe: Arc<dyn ModelServerProbe>,
|
||||
downloader: Arc<dyn ModelArtifactDownloader>,
|
||||
downloads: Arc<dyn ModelArtifactDownloadTracker>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
live: Arc<dyn LiveAgentRegistry>,
|
||||
}
|
||||
|
||||
impl DeleteModelArtifact {
|
||||
/// Builds the use case.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
registry: Arc<dyn ModelServerRegistry>,
|
||||
probe: Arc<dyn ModelServerProbe>,
|
||||
downloader: Arc<dyn ModelArtifactDownloader>,
|
||||
downloads: Arc<dyn ModelArtifactDownloadTracker>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
live: Arc<dyn LiveAgentRegistry>,
|
||||
) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
probe,
|
||||
downloader,
|
||||
downloads,
|
||||
profiles,
|
||||
projects,
|
||||
contexts,
|
||||
live,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a managed Hugging Face artifact after safety checks.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::ModelServer`] when the server is missing, the source is not
|
||||
/// deletable, a download is active, or a live agent uses the server.
|
||||
pub async fn execute(&self, input: DeleteModelArtifactInput) -> Result<(), AppError> {
|
||||
let config = self
|
||||
.registry
|
||||
.get(&input.server_id)
|
||||
.await?
|
||||
.ok_or(ModelServerError::NotConfigured)?;
|
||||
let Some(ModelSource::HuggingFace { repo }) = config.model.source.as_ref() else {
|
||||
return Err(ModelServerError::Invalid(
|
||||
"only managed Hugging Face model artifacts can be deleted".to_owned(),
|
||||
)
|
||||
.into());
|
||||
};
|
||||
if self
|
||||
.downloads
|
||||
.is_model_artifact_download_in_progress(input.server_id)
|
||||
{
|
||||
return Err(ModelServerError::InUse(format!(
|
||||
"model artifact download in progress for {}",
|
||||
input.server_id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
self.ensure_server_not_reachable(&config).await?;
|
||||
self.ensure_not_used_by_live_agent(input.server_id).await?;
|
||||
self.downloader.delete_hf_model(repo).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_server_not_reachable(
|
||||
&self,
|
||||
config: &LocalModelServerConfig,
|
||||
) -> Result<(), AppError> {
|
||||
match self.probe.probe(&config.endpoint).await? {
|
||||
ModelServerStatus::Unreachable => Ok(()),
|
||||
ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted => {
|
||||
Err(ModelServerError::InUse(format!(
|
||||
"model server {} is currently reachable",
|
||||
config.id
|
||||
))
|
||||
.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_not_used_by_live_agent(
|
||||
&self,
|
||||
server_id: LocalModelServerId,
|
||||
) -> Result<(), AppError> {
|
||||
let profiles = self.profiles.list().await?;
|
||||
let profile_server: HashMap<_, _> = profiles
|
||||
.iter()
|
||||
.filter_map(|profile| {
|
||||
profile
|
||||
.opencode
|
||||
.as_ref()
|
||||
.and_then(|opencode| opencode.local_model_server_id)
|
||||
.map(|id| (profile.id, id))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut agents_by_project = HashMap::new();
|
||||
for snapshot in self.live.live_agent_snapshots() {
|
||||
let agents = if let Some(agents) = agents_by_project.get(&snapshot.project_id) {
|
||||
agents
|
||||
} else {
|
||||
let project = self.projects.load_project(snapshot.project_id).await?;
|
||||
let manifest = self.contexts.load_manifest(&project).await?;
|
||||
agents_by_project.insert(
|
||||
snapshot.project_id,
|
||||
manifest
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
entry
|
||||
.to_agent()
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
);
|
||||
agents_by_project
|
||||
.get(&snapshot.project_id)
|
||||
.expect("project agents inserted")
|
||||
};
|
||||
let Some(agent) = agents.iter().find(|agent| agent.id == snapshot.agent_id) else {
|
||||
continue;
|
||||
};
|
||||
if profile_server.get(&agent.profile_id) == Some(&server_id) {
|
||||
return Err(ModelServerError::InUse(format!(
|
||||
"model server {server_id} is used by live agent {}",
|
||||
agent.id
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`EnsureLocalModelServer::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EnsureLocalModelServerInput {
|
||||
@ -738,6 +988,15 @@ impl EnsureLocalModelServer {
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelArtifactDownloadTracker for EnsureLocalModelServer {
|
||||
fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool {
|
||||
self.download_cancels
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains_key(&server_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn ready(config: &LocalModelServerConfig, status: ModelServerStatus) -> ModelServerReady {
|
||||
ModelServerReady {
|
||||
base_url: config.endpoint.base_url.clone(),
|
||||
|
||||
@ -127,6 +127,22 @@ fn resolve_background_cwd(
|
||||
ProjectPath::new(path).map_err(|_| AppError::Invalid("invalid background task cwd".to_owned()))
|
||||
}
|
||||
|
||||
fn rendezvous_context_for_task(task: &BackgroundTask) -> Option<domain::RendezvousContext> {
|
||||
match &task.kind {
|
||||
BackgroundTaskKind::HeadlessRendezvous {
|
||||
requester_agent_id,
|
||||
target_agent_id,
|
||||
conversation_id,
|
||||
..
|
||||
} => Some(domain::RendezvousContext {
|
||||
requester_agent_id: *requester_agent_id,
|
||||
target_agent_id: *target_agent_id,
|
||||
conversation_id: *conversation_id,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_path_no_parent(path: &Path) -> Result<PathBuf, AppError> {
|
||||
let mut out = PathBuf::new();
|
||||
for component in path.components() {
|
||||
@ -847,22 +863,26 @@ impl OrchestratorService {
|
||||
if task.is_terminal() {
|
||||
return Ok(());
|
||||
}
|
||||
let rendezvous = rendezvous_context_for_task(&task);
|
||||
let event = match &result {
|
||||
BackgroundTaskResult::Success { .. } => DomainEvent::BackgroundTaskCompleted {
|
||||
project_id: project.id,
|
||||
task_id,
|
||||
owner_agent_id,
|
||||
rendezvous,
|
||||
},
|
||||
BackgroundTaskResult::Failure { .. } => DomainEvent::BackgroundTaskFailed {
|
||||
project_id: project.id,
|
||||
task_id,
|
||||
owner_agent_id,
|
||||
rendezvous,
|
||||
},
|
||||
BackgroundTaskResult::Cancelled { .. } | BackgroundTaskResult::Expired { .. } => {
|
||||
DomainEvent::BackgroundTaskCancelled {
|
||||
project_id: project.id,
|
||||
task_id,
|
||||
owner_agent_id,
|
||||
rendezvous,
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -2967,6 +2987,52 @@ mod tests {
|
||||
assert_eq!(submit.delay_ms, Some(CODEX_SUBMIT_DELAY_MS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendezvous_context_is_extracted_from_headless_background_task_kind() {
|
||||
let requester = aid(1);
|
||||
let target = aid(2);
|
||||
let conversation_id = domain::ConversationId::from_uuid(uuid::Uuid::from_u128(3));
|
||||
let task = BackgroundTask::new(
|
||||
TaskId::from_uuid(uuid::Uuid::from_u128(4)),
|
||||
domain::ProjectId::from_uuid(uuid::Uuid::from_u128(5)),
|
||||
target,
|
||||
BackgroundTaskKind::HeadlessRendezvous {
|
||||
requester_agent_id: Some(requester),
|
||||
target_agent_id: target,
|
||||
ticket_id: TicketId::from_uuid(uuid::Uuid::from_u128(6)),
|
||||
conversation_id,
|
||||
},
|
||||
BackgroundTaskWakePolicy::RecordOnly,
|
||||
100,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let context = rendezvous_context_for_task(&task).expect("rendezvous context");
|
||||
|
||||
assert_eq!(context.requester_agent_id, Some(requester));
|
||||
assert_eq!(context.target_agent_id, target);
|
||||
assert_eq!(context.conversation_id, conversation_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendezvous_context_is_absent_for_command_background_task() {
|
||||
let task = BackgroundTask::new(
|
||||
TaskId::from_uuid(uuid::Uuid::from_u128(7)),
|
||||
domain::ProjectId::from_uuid(uuid::Uuid::from_u128(8)),
|
||||
aid(9),
|
||||
BackgroundTaskKind::Command {
|
||||
label: "cargo test".to_owned(),
|
||||
},
|
||||
BackgroundTaskWakePolicy::RecordOnly,
|
||||
100,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rendezvous_context_for_task(&task), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_profile_submit_delay_is_preserved() {
|
||||
let p = profile(3, "OpenAI Codex CLI", "codex")
|
||||
|
||||
@ -67,6 +67,11 @@ pub trait LiveAgentRegistry: Send + Sync {
|
||||
/// be keyed on the hosting node, not the agent (otherwise a duplicate leaf
|
||||
/// would be wrongly marked as still running).
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool;
|
||||
|
||||
/// Snapshots every live agent session currently known by this registry.
|
||||
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory registry of active terminal sessions.
|
||||
@ -92,6 +97,26 @@ impl LiveAgentRegistry for TerminalSessions {
|
||||
.map(|m| m.values().any(|e| e.session.node_id == *node_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.filter_map(|e| match e.session.kind {
|
||||
SessionKind::Agent { agent_id } => Some(LiveSessionSnapshot {
|
||||
project_id: e.project_id,
|
||||
agent_id,
|
||||
node_id: e.session.node_id,
|
||||
session_id: e.session.id,
|
||||
kind: LiveSessionKind::Pty,
|
||||
}),
|
||||
SessionKind::Plain => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalSessions {
|
||||
@ -444,6 +469,23 @@ impl LiveAgentRegistry for StructuredSessions {
|
||||
.map(|m| m.values().any(|e| e.node_id == *node_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.map(|e| LiveSessionSnapshot {
|
||||
project_id: e.project_id,
|
||||
agent_id: e.agent_id,
|
||||
node_id: e.node_id,
|
||||
session_id: e.session.id(),
|
||||
kind: LiveSessionKind::Structured,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl StructuredSessions {
|
||||
@ -853,42 +895,8 @@ impl LiveSessions {
|
||||
/// Tous les agents vivants avec le type de registre source (PTY puis structuré).
|
||||
#[must_use]
|
||||
pub fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
|
||||
let mut all: Vec<LiveSessionSnapshot> = self
|
||||
.pty
|
||||
.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.filter_map(|e| match e.session.kind {
|
||||
SessionKind::Agent { agent_id } => Some(LiveSessionSnapshot {
|
||||
project_id: e.project_id,
|
||||
agent_id,
|
||||
node_id: e.session.node_id,
|
||||
session_id: e.session.id,
|
||||
kind: LiveSessionKind::Pty,
|
||||
}),
|
||||
SessionKind::Plain => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
all.extend(
|
||||
self.structured
|
||||
.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.map(|e| LiveSessionSnapshot {
|
||||
project_id: e.project_id,
|
||||
agent_id: e.agent_id,
|
||||
node_id: e.node_id,
|
||||
session_id: e.session.id(),
|
||||
kind: LiveSessionKind::Structured,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let mut all = self.pty.live_agent_snapshots();
|
||||
all.extend(self.structured.live_agent_snapshots());
|
||||
all
|
||||
}
|
||||
}
|
||||
@ -902,4 +910,8 @@ impl LiveAgentRegistry for LiveSessions {
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool {
|
||||
self.pty.is_node_live(node_id) || self.structured.is_node_live(node_id)
|
||||
}
|
||||
|
||||
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
|
||||
LiveSessions::live_agent_snapshots(self)
|
||||
}
|
||||
}
|
||||
|
||||
@ -231,6 +231,12 @@ pub struct AgentBackgroundTaskState {
|
||||
pub stdout_tail: Option<String>,
|
||||
/// Bounded stderr tail.
|
||||
pub stderr_tail: Option<String>,
|
||||
/// Agent that requested a headless rendezvous, when this task is one.
|
||||
pub requester_agent_id: Option<AgentId>,
|
||||
/// Target agent for a headless rendezvous, when this task is one.
|
||||
pub target_agent_id: Option<AgentId>,
|
||||
/// Conversation opened by a headless rendezvous, when this task is one.
|
||||
pub conversation_id: Option<domain::ConversationId>,
|
||||
/// Creation timestamp, epoch milliseconds.
|
||||
pub created_at_ms: u64,
|
||||
/// Last update timestamp, epoch milliseconds.
|
||||
@ -592,6 +598,8 @@ impl GetProjectWorkState {
|
||||
impl From<BackgroundTask> for AgentBackgroundTaskState {
|
||||
fn from(task: BackgroundTask) -> Self {
|
||||
let (exit_code, summary, stdout_tail, stderr_tail) = flatten_background_result(&task);
|
||||
let (requester_agent_id, target_agent_id, conversation_id) =
|
||||
flatten_background_rendezvous_context(&task.kind);
|
||||
Self {
|
||||
task_id: task.id,
|
||||
kind: BackgroundTaskKindLabel::from(&task.kind),
|
||||
@ -600,12 +608,37 @@ impl From<BackgroundTask> for AgentBackgroundTaskState {
|
||||
summary,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
requester_agent_id,
|
||||
target_agent_id,
|
||||
conversation_id,
|
||||
created_at_ms: task.created_at_ms,
|
||||
updated_at_ms: task.updated_at_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten_background_rendezvous_context(
|
||||
kind: &BackgroundTaskKind,
|
||||
) -> (
|
||||
Option<AgentId>,
|
||||
Option<AgentId>,
|
||||
Option<domain::ConversationId>,
|
||||
) {
|
||||
match kind {
|
||||
BackgroundTaskKind::HeadlessRendezvous {
|
||||
requester_agent_id,
|
||||
target_agent_id,
|
||||
conversation_id,
|
||||
..
|
||||
} => (
|
||||
*requester_agent_id,
|
||||
Some(*target_agent_id),
|
||||
Some(*conversation_id),
|
||||
),
|
||||
_ => (None, None, None),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&BackgroundTaskKind> for BackgroundTaskKindLabel {
|
||||
fn from(kind: &BackgroundTaskKind) -> Self {
|
||||
match kind {
|
||||
|
||||
@ -7,35 +7,52 @@ use std::time::Duration;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::{
|
||||
DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput,
|
||||
ModelServerReadinessPolicy, TerminalSessions,
|
||||
DeleteModelArtifact, DeleteModelArtifactInput, DeleteModelServer, DeleteModelServerInput,
|
||||
EnsureLocalModelServer, EnsureLocalModelServerInput, LiveAgentRegistry, LiveSessionKind,
|
||||
LiveSessionSnapshot, ModelArtifactDownloadTracker, ModelServerReadinessPolicy, TerminalSessions,
|
||||
};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::layout::Workspace;
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::model_server::{
|
||||
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelServerLifecycleStatus,
|
||||
ModelServerStatus, ModelSource, StopPolicy,
|
||||
};
|
||||
use domain::ports::{
|
||||
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
|
||||
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
|
||||
ModelServerArgv, ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime,
|
||||
ProcessStatus, ProfileStore, RemotePath, SpawnSpec, StoreError,
|
||||
AgentContextStore, DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess,
|
||||
ManagedProcessHandle, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
||||
ModelArtifactResolution, ModelArtifactState, ModelServerArgv, ModelServerError,
|
||||
ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore,
|
||||
ProjectStore, RemotePath, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
||||
use domain::project::Project;
|
||||
use domain::{
|
||||
AgentId, LocalModelServerId, NodeId, ProfileId, ProjectId, ProjectPath, PtySize, SessionId,
|
||||
SessionKind, SessionStatus, TerminalSession,
|
||||
AgentId, AgentManifest, LocalModelServerId, ManifestEntry, NodeId, ProfileId, ProjectId,
|
||||
ProjectPath, PtySize, RemoteRef, SessionId, SessionKind, SessionStatus, TerminalSession,
|
||||
};
|
||||
|
||||
fn sid(n: u128) -> LocalModelServerId {
|
||||
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn pid(n: u128) -> ProjectId {
|
||||
ProjectId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn sess(n: u128) -> SessionId {
|
||||
SessionId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn config(
|
||||
id: LocalModelServerId,
|
||||
port: u16,
|
||||
@ -301,18 +318,27 @@ enum FakeDownloadOutcome {
|
||||
|
||||
struct FakeModelArtifactDownloader {
|
||||
outcome: Mutex<FakeDownloadOutcome>,
|
||||
deleted: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeModelArtifactDownloader {
|
||||
fn new(outcome: FakeDownloadOutcome) -> Self {
|
||||
Self {
|
||||
outcome: Mutex::new(outcome),
|
||||
deleted: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelArtifactDownloader for FakeModelArtifactDownloader {
|
||||
async fn hf_model_state(
|
||||
&self,
|
||||
_repo: &HfModelRef,
|
||||
) -> Result<ModelArtifactState, ModelServerError> {
|
||||
Ok(ModelArtifactState::Missing)
|
||||
}
|
||||
|
||||
async fn resolve_hf_model(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
@ -354,6 +380,123 @@ impl ModelArtifactDownloader for FakeModelArtifactDownloader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
|
||||
self.deleted.lock().unwrap().push(repo.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeDownloadTracker {
|
||||
in_progress: Mutex<Vec<LocalModelServerId>>,
|
||||
}
|
||||
|
||||
impl ModelArtifactDownloadTracker for FakeDownloadTracker {
|
||||
fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool {
|
||||
self.in_progress.lock().unwrap().contains(&server_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeLive {
|
||||
snapshots: Vec<LiveSessionSnapshot>,
|
||||
}
|
||||
|
||||
impl LiveAgentRegistry for FakeLive {
|
||||
fn is_agent_live(&self, _project_id: ProjectId, _agent_id: &AgentId) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_node_live(&self, _node_id: &NodeId) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn live_agent_snapshots(&self) -> Vec<application::LiveSessionSnapshot> {
|
||||
self.snapshots.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeProjects {
|
||||
project_id: ProjectId,
|
||||
}
|
||||
|
||||
impl Default for FakeProjects {
|
||||
fn default() -> Self {
|
||||
Self { project_id: pid(1) }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectStore for FakeProjects {
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
||||
if id != self.project_id {
|
||||
return Err(StoreError::NotFound);
|
||||
}
|
||||
Project::new(
|
||||
id,
|
||||
"Test",
|
||||
ProjectPath::new("/tmp/unused").unwrap(),
|
||||
RemoteRef::Local,
|
||||
0,
|
||||
)
|
||||
.map_err(|err| StoreError::Invalid(err.to_string()))
|
||||
}
|
||||
|
||||
async fn save_project(&self, _project: &Project) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_workspace(&self, _workspace: &Workspace) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
|
||||
Ok(Workspace {
|
||||
windows: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeContexts {
|
||||
manifest: AgentManifest,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentContextStore for FakeContexts {
|
||||
async fn read_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_agent: &AgentId,
|
||||
) -> Result<MarkdownDoc, StoreError> {
|
||||
Ok(MarkdownDoc::new(""))
|
||||
}
|
||||
|
||||
async fn write_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_agent: &AgentId,
|
||||
_md: &MarkdownDoc,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
||||
Ok(self.manifest.clone())
|
||||
}
|
||||
|
||||
async fn save_manifest(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_manifest: &AgentManifest,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@ -434,6 +577,46 @@ fn ensure_with_downloader(
|
||||
.with_model_artifact_downloader(downloader as Arc<dyn ModelArtifactDownloader>)
|
||||
}
|
||||
|
||||
fn delete_artifact_usecase(
|
||||
registry: Arc<FakeRegistry>,
|
||||
downloader: Arc<FakeModelArtifactDownloader>,
|
||||
tracker: Arc<FakeDownloadTracker>,
|
||||
profiles: Arc<FakeProfiles>,
|
||||
) -> DeleteModelArtifact {
|
||||
delete_artifact_usecase_with_live(
|
||||
registry,
|
||||
downloader,
|
||||
tracker,
|
||||
profiles,
|
||||
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
|
||||
Arc::new(FakeProjects::default()),
|
||||
Arc::new(FakeContexts::default()),
|
||||
Arc::new(FakeLive::default()),
|
||||
)
|
||||
}
|
||||
|
||||
fn delete_artifact_usecase_with_live(
|
||||
registry: Arc<FakeRegistry>,
|
||||
downloader: Arc<FakeModelArtifactDownloader>,
|
||||
tracker: Arc<FakeDownloadTracker>,
|
||||
profiles: Arc<FakeProfiles>,
|
||||
probe: Arc<FakeProbe>,
|
||||
projects: Arc<FakeProjects>,
|
||||
contexts: Arc<FakeContexts>,
|
||||
live: Arc<FakeLive>,
|
||||
) -> DeleteModelArtifact {
|
||||
DeleteModelArtifact::new(
|
||||
registry as Arc<dyn ModelServerRegistry>,
|
||||
probe as Arc<dyn ModelServerProbe>,
|
||||
downloader as Arc<dyn ModelArtifactDownloader>,
|
||||
tracker as Arc<dyn ModelArtifactDownloadTracker>,
|
||||
profiles as Arc<dyn ProfileStore>,
|
||||
projects as Arc<dyn ProjectStore>,
|
||||
contexts as Arc<dyn AgentContextStore>,
|
||||
live as Arc<dyn LiveAgentRegistry>,
|
||||
)
|
||||
}
|
||||
|
||||
fn progress(downloaded: Option<u64>, total: Option<u64>) -> ModelArtifactProgress {
|
||||
ModelArtifactProgress {
|
||||
downloaded_bytes: downloaded,
|
||||
@ -1409,3 +1592,218 @@ async fn delete_model_server_removes_unused_config() {
|
||||
|
||||
assert!(registry.get(&sid(9)).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_artifact_refuses_local_path_source() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(25), 8105, "/models/qwen.gguf", false))
|
||||
.await
|
||||
.unwrap();
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: Vec::new(),
|
||||
path: "/cache/model.gguf",
|
||||
cache_hit: true,
|
||||
},
|
||||
));
|
||||
let usecase = delete_artifact_usecase(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&downloader),
|
||||
Arc::new(FakeDownloadTracker::default()),
|
||||
Arc::new(FakeProfiles::default()),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(DeleteModelArtifactInput { server_id: sid(25) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
application::AppError::ModelServer { code, .. } => assert_eq!(code, "invalid"),
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
assert!(downloader.deleted.lock().unwrap().is_empty());
|
||||
assert!(registry.get(&sid(25)).await.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_artifact_refuses_download_in_progress() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(sid(26), 8106, "Qwen/Qwen3-Coder:Q4_K_M"))
|
||||
.await
|
||||
.unwrap();
|
||||
let tracker = Arc::new(FakeDownloadTracker::default());
|
||||
tracker.in_progress.lock().unwrap().push(sid(26));
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: Vec::new(),
|
||||
path: "/cache/q4.gguf",
|
||||
cache_hit: true,
|
||||
},
|
||||
));
|
||||
let usecase = delete_artifact_usecase(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&downloader),
|
||||
tracker,
|
||||
Arc::new(FakeProfiles::default()),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(DeleteModelArtifactInput { server_id: sid(26) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
application::AppError::ModelServer { code, .. } => {
|
||||
assert_eq!(code, "model_server_in_use");
|
||||
}
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
assert!(downloader.deleted.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_artifact_refuses_live_agent_using_server_profile() {
|
||||
let server_id = sid(27);
|
||||
let project_id = pid(27);
|
||||
let agent_id = aid(27);
|
||||
let profile_id = ProfileId::from_uuid(uuid::Uuid::from_u128(270));
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(server_id, 8107, "Qwen/Qwen3-Coder:Q4_K_M"))
|
||||
.await
|
||||
.unwrap();
|
||||
let profiles = Arc::new(FakeProfiles(Mutex::new(vec![opencode_profile(
|
||||
profile_id.as_uuid().as_u128(),
|
||||
server_id,
|
||||
)])));
|
||||
let contexts = Arc::new(FakeContexts {
|
||||
manifest: AgentManifest::new(
|
||||
1,
|
||||
vec![ManifestEntry::new(
|
||||
agent_id,
|
||||
"Local Agent",
|
||||
"agents/local.md",
|
||||
profile_id,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap()],
|
||||
)
|
||||
.unwrap(),
|
||||
});
|
||||
let live = Arc::new(FakeLive {
|
||||
snapshots: vec![LiveSessionSnapshot {
|
||||
project_id,
|
||||
agent_id,
|
||||
node_id: nid(27),
|
||||
session_id: sess(27),
|
||||
kind: LiveSessionKind::Pty,
|
||||
}],
|
||||
});
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: Vec::new(),
|
||||
path: "/cache/q4.gguf",
|
||||
cache_hit: true,
|
||||
},
|
||||
));
|
||||
let usecase = delete_artifact_usecase_with_live(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&downloader),
|
||||
Arc::new(FakeDownloadTracker::default()),
|
||||
profiles,
|
||||
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
|
||||
Arc::new(FakeProjects { project_id }),
|
||||
contexts,
|
||||
live,
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(DeleteModelArtifactInput { server_id })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
application::AppError::ModelServer { code, .. } => {
|
||||
assert_eq!(code, "model_server_in_use");
|
||||
}
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
assert!(downloader.deleted.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_artifact_refuses_reachable_server_endpoint() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(sid(28), 8108, "Qwen/Qwen3-Coder:Q4_K_M"))
|
||||
.await
|
||||
.unwrap();
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: Vec::new(),
|
||||
path: "/cache/q4.gguf",
|
||||
cache_hit: true,
|
||||
},
|
||||
));
|
||||
let usecase = delete_artifact_usecase_with_live(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&downloader),
|
||||
Arc::new(FakeDownloadTracker::default()),
|
||||
Arc::new(FakeProfiles::default()),
|
||||
Arc::new(FakeProbe::new(vec![ModelServerStatus::ReadyReused])),
|
||||
Arc::new(FakeProjects::default()),
|
||||
Arc::new(FakeContexts::default()),
|
||||
Arc::new(FakeLive::default()),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(DeleteModelArtifactInput { server_id: sid(28) })
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
application::AppError::ModelServer { code, .. } => {
|
||||
assert_eq!(code, "model_server_in_use");
|
||||
}
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
assert!(downloader.deleted.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_model_artifact_deletes_hf_cache_without_deleting_config() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(hf_config(sid(27), 8107, "Qwen/Qwen3-Coder:Q4_K_M"))
|
||||
.await
|
||||
.unwrap();
|
||||
let downloader = Arc::new(FakeModelArtifactDownloader::new(
|
||||
FakeDownloadOutcome::Resolve {
|
||||
progress: Vec::new(),
|
||||
path: "/cache/q4.gguf",
|
||||
cache_hit: true,
|
||||
},
|
||||
));
|
||||
let usecase = delete_artifact_usecase(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&downloader),
|
||||
Arc::new(FakeDownloadTracker::default()),
|
||||
Arc::new(FakeProfiles::default()),
|
||||
);
|
||||
|
||||
usecase
|
||||
.execute(DeleteModelArtifactInput { server_id: sid(27) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
downloader.deleted.lock().unwrap().as_slice(),
|
||||
["Qwen/Qwen3-Coder:Q4_K_M"]
|
||||
);
|
||||
assert!(registry.get(&sid(27)).await.unwrap().is_some());
|
||||
}
|
||||
|
||||
@ -24,11 +24,12 @@ use domain::profile::{
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
use application::{
|
||||
reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed,
|
||||
CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles,
|
||||
ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveProfile,
|
||||
SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
||||
claude_model_catalogue, codex_model_catalogue, reference_profile_id, reference_profiles,
|
||||
AppError, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||
CloneProfileFromSeed, CloneProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput,
|
||||
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState,
|
||||
ListProfiles, ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||
SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -869,6 +870,114 @@ async fn clone_opencode_profile_falls_back_to_catalogue_when_persisted_seed_is_n
|
||||
assert_eq!(out.profile.name, "OpenCode + llama.cpp copy");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clone_profile_from_seed_creates_codex_profile_with_fresh_id_and_model_override() {
|
||||
let store = FakeProfileStore::default();
|
||||
let clone = CloneProfileFromSeed::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3801)])),
|
||||
);
|
||||
|
||||
let out = clone
|
||||
.execute(CloneProfileFromSeedInput {
|
||||
seed_profile_id: reference_profile_id("codex"),
|
||||
name: Some("Codex GPT-5".to_owned()),
|
||||
model: Some("gpt-5-codex".to_owned()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
out.profile.id,
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(3801))
|
||||
);
|
||||
assert_eq!(out.profile.name, "Codex GPT-5");
|
||||
assert_eq!(out.profile.model.as_deref(), Some("gpt-5-codex"));
|
||||
assert_eq!(
|
||||
out.profile.structured_adapter,
|
||||
Some(StructuredAdapter::Codex)
|
||||
);
|
||||
assert_eq!(store.0.lock().unwrap().profiles, vec![out.profile]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clone_profile_from_seed_prefers_persisted_seed_and_preserves_model_by_default() {
|
||||
let store = FakeProfileStore::default();
|
||||
let persisted = reference_profiles()
|
||||
.into_iter()
|
||||
.find(|profile| profile.id == reference_profile_id("claude"))
|
||||
.expect("seed exists")
|
||||
.with_model("claude-opus-4-8");
|
||||
SaveProfile::new(Arc::new(store.clone()))
|
||||
.execute(SaveProfileInput {
|
||||
profile: persisted.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let clone = CloneProfileFromSeed::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3802)])),
|
||||
);
|
||||
let out = clone
|
||||
.execute(CloneProfileFromSeedInput {
|
||||
seed_profile_id: persisted.id,
|
||||
name: None,
|
||||
model: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.profile.name, "Claude Code copy");
|
||||
assert_eq!(out.profile.model.as_deref(), Some("claude-opus-4-8"));
|
||||
assert_eq!(
|
||||
out.profile.structured_adapter,
|
||||
Some(StructuredAdapter::Claude)
|
||||
);
|
||||
assert_ne!(out.profile.id, persisted.id);
|
||||
assert_eq!(store.0.lock().unwrap().profiles.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clone_profile_from_seed_rejects_blank_model_override() {
|
||||
let store = FakeProfileStore::default();
|
||||
let clone = CloneProfileFromSeed::new(
|
||||
Arc::new(store),
|
||||
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3803)])),
|
||||
);
|
||||
|
||||
let err = clone
|
||||
.execute(CloneProfileFromSeedInput {
|
||||
seed_profile_id: reference_profile_id("claude"),
|
||||
name: Some("Claude blank".to_owned()),
|
||||
model: Some(" ".to_owned()),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, AppError::Invalid(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clone_profile_from_seed_rejects_blank_name_override() {
|
||||
let store = FakeProfileStore::default();
|
||||
let clone = CloneProfileFromSeed::new(
|
||||
Arc::new(store),
|
||||
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3804)])),
|
||||
);
|
||||
|
||||
let err = clone
|
||||
.execute(CloneProfileFromSeedInput {
|
||||
seed_profile_id: reference_profile_id("codex"),
|
||||
name: Some(" ".to_owned()),
|
||||
model: Some("gpt-5-codex".to_owned()),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, AppError::Invalid(_)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReferenceProfiles / catalogue
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -1049,3 +1158,22 @@ fn catalogue_gemini_and_aider_stay_pty_without_adapter() {
|
||||
assert_eq!(by_command["gemini"].structured_adapter, None);
|
||||
assert_eq!(by_command["aider"].structured_adapter, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_and_codex_model_catalogues_are_static_and_searchable() {
|
||||
let claude = claude_model_catalogue();
|
||||
let codex = codex_model_catalogue();
|
||||
|
||||
assert!(claude
|
||||
.iter()
|
||||
.any(|model| model.model_id == "claude-sonnet-5" && model.recommended));
|
||||
assert!(codex
|
||||
.iter()
|
||||
.any(|model| model.model_id == "gpt-5-codex" && model.recommended));
|
||||
assert!(claude
|
||||
.iter()
|
||||
.all(|model| model.adapter == StructuredAdapter::Claude && !model.display_name.is_empty()));
|
||||
assert!(codex
|
||||
.iter()
|
||||
.all(|model| model.adapter == StructuredAdapter::Codex && !model.display_name.is_empty()));
|
||||
}
|
||||
|
||||
@ -1047,6 +1047,78 @@ impl From<CloneOpenCodeProfileFromSeedOutput> for ProfileDto {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<application::CloneProfileFromSeedOutput> for ProfileDto {
|
||||
fn from(out: application::CloneProfileFromSeedOutput) -> Self {
|
||||
Self(out.profile)
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of a curated structured-profile model catalogue.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileModelCatalogEntryDto {
|
||||
/// Structured adapter this model belongs to.
|
||||
pub adapter: domain::profile::StructuredAdapter,
|
||||
/// Exact model identifier to persist on `AgentProfile.model`.
|
||||
pub model_id: String,
|
||||
/// Human-readable label for picker display.
|
||||
pub display_name: String,
|
||||
/// Extra search tokens useful to the frontend.
|
||||
pub aliases: Vec<String>,
|
||||
/// Whether this entry is the conservative default suggestion.
|
||||
pub recommended: bool,
|
||||
/// Compatibility state against the locally detected CLI version.
|
||||
pub compatibility: domain::ModelCompatibility,
|
||||
/// Source that contributed the model entry.
|
||||
pub source: domain::ModelCatalogSource,
|
||||
}
|
||||
|
||||
impl From<application::ProfileModelCatalogEntry> for ProfileModelCatalogEntryDto {
|
||||
fn from(entry: application::ProfileModelCatalogEntry) -> Self {
|
||||
Self {
|
||||
adapter: entry.adapter,
|
||||
model_id: entry.model_id,
|
||||
display_name: entry.display_name,
|
||||
aliases: entry.aliases,
|
||||
recommended: entry.recommended,
|
||||
compatibility: entry.compatibility,
|
||||
source: entry.source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enriched structured-profile model catalogue.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileModelCatalogDto {
|
||||
/// The catalogue entries.
|
||||
pub models: Vec<ProfileModelCatalogEntryDto>,
|
||||
/// Best-effort local CLI version.
|
||||
pub cli_version: Option<String>,
|
||||
/// Non-fatal fallback/degradation warnings.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<application::ListClaudeModelsOutput> for ProfileModelCatalogDto {
|
||||
fn from(out: application::ListClaudeModelsOutput) -> Self {
|
||||
Self {
|
||||
models: out.models.into_iter().map(Into::into).collect(),
|
||||
cli_version: out.cli_version.map(|version| version.raw),
|
||||
warnings: out.warnings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<application::ListCodexModelsOutput> for ProfileModelCatalogDto {
|
||||
fn from(out: application::ListCodexModelsOutput) -> Self {
|
||||
Self {
|
||||
models: out.models.into_iter().map(Into::into).collect(),
|
||||
cli_version: out.cli_version.map(|version| version.raw),
|
||||
warnings: out.warnings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of the static OpenCode cloud-provider catalogue (ticket #92, lot B3).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -1202,6 +1274,30 @@ impl From<CloneOpenCodeProfileFromSeedRequestDto> for CloneOpenCodeProfileFromSe
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `clone_profile_from_seed`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CloneProfileFromSeedRequestDto {
|
||||
/// Id of the persisted or reference profile to clone.
|
||||
pub seed_profile_id: domain::ids::ProfileId,
|
||||
/// Optional display name for the new profile.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// Optional model override. When omitted, the seed model is copied.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
impl From<CloneProfileFromSeedRequestDto> for application::CloneProfileFromSeedInput {
|
||||
fn from(dto: CloneProfileFromSeedRequestDto) -> Self {
|
||||
Self {
|
||||
seed_profile_id: dto.seed_profile_id,
|
||||
name: dto.name,
|
||||
model: dto.model,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `configure_profiles` (closes the first run).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -1247,7 +1343,10 @@ impl From<FirstRunStateOutput> for FirstRunStateDto {
|
||||
// Local model servers (B35)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput};
|
||||
use application::{
|
||||
ListModelServersOutput, ModelArtifactView, ModelServerListItem, SaveModelServerInput,
|
||||
SaveModelServerOutput,
|
||||
};
|
||||
use domain::model_server::{
|
||||
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource,
|
||||
@ -1396,6 +1495,9 @@ pub struct ModelServerConfigDto {
|
||||
/// Optional readiness warmup deadline override in seconds.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub warmup_deadline_secs: Option<u64>,
|
||||
/// Derived local artifact cache state.
|
||||
#[serde(default)]
|
||||
pub artifact: ModelArtifactDto,
|
||||
}
|
||||
|
||||
impl ModelServerConfigDto {
|
||||
@ -1420,6 +1522,7 @@ impl ModelServerConfigDto {
|
||||
auto_start: config.auto_start,
|
||||
stop_policy: config.stop_policy.into(),
|
||||
warmup_deadline_secs: config.warmup_deadline_secs,
|
||||
artifact: ModelArtifactDto::NotManaged,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1467,6 +1570,53 @@ impl ModelServerConfigDto {
|
||||
}
|
||||
}
|
||||
|
||||
/// Local model artifact cache state on the IPC wire.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "state")]
|
||||
pub enum ModelArtifactDto {
|
||||
/// The configured source is not managed by IdeA's downloader.
|
||||
NotManaged,
|
||||
/// The configured source is managed but not present in cache.
|
||||
Missing,
|
||||
/// A download/prepare operation is currently running for this server.
|
||||
Downloading,
|
||||
/// The configured source is present in cache.
|
||||
Downloaded {
|
||||
/// Local artifact path.
|
||||
path: String,
|
||||
/// Total on-disk size when known.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
size_bytes: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for ModelArtifactDto {
|
||||
fn default() -> Self {
|
||||
Self::NotManaged
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ModelArtifactView> for ModelArtifactDto {
|
||||
fn from(view: ModelArtifactView) -> Self {
|
||||
match view {
|
||||
ModelArtifactView::NotManaged => Self::NotManaged,
|
||||
ModelArtifactView::Missing => Self::Missing,
|
||||
ModelArtifactView::Downloading => Self::Downloading,
|
||||
ModelArtifactView::Downloaded { path, size_bytes } => {
|
||||
Self::Downloaded { path, size_bytes }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ModelServerListItem> for ModelServerConfigDto {
|
||||
fn from(item: ModelServerListItem) -> Self {
|
||||
let mut dto = Self::from_domain(item.config);
|
||||
dto.artifact = item.artifact.into();
|
||||
dto
|
||||
}
|
||||
}
|
||||
|
||||
/// Response DTO for `preview_model_server_command`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -1489,7 +1639,7 @@ impl From<ListModelServersOutput> for ModelServerConfigListDto {
|
||||
Self(
|
||||
out.servers
|
||||
.into_iter()
|
||||
.map(ModelServerConfigDto::from_domain)
|
||||
.map(ModelServerConfigDto::from)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@ -2461,6 +2611,15 @@ pub struct AgentBackgroundTaskStateDto {
|
||||
/// Bounded stderr tail.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stderr_tail: Option<String>,
|
||||
/// Agent that requested a headless rendezvous, when known.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub requester_agent_id: Option<String>,
|
||||
/// Target agent for a headless rendezvous.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target_agent_id: Option<String>,
|
||||
/// Conversation opened by a headless rendezvous.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
/// Creation timestamp, epoch milliseconds.
|
||||
pub created_at_ms: u64,
|
||||
/// Last update timestamp, epoch milliseconds.
|
||||
@ -2477,6 +2636,9 @@ impl From<AgentBackgroundTaskState> for AgentBackgroundTaskStateDto {
|
||||
summary: task.summary,
|
||||
stdout_tail: task.stdout_tail,
|
||||
stderr_tail: task.stderr_tail,
|
||||
requester_agent_id: task.requester_agent_id.map(|id| id.to_string()),
|
||||
target_agent_id: task.target_agent_id.map(|id| id.to_string()),
|
||||
conversation_id: task.conversation_id.map(|id| id.to_string()),
|
||||
created_at_ms: task.created_at_ms,
|
||||
updated_at_ms: task.updated_at_ms,
|
||||
}
|
||||
@ -3902,6 +4064,15 @@ pub struct BackgroundTaskDto {
|
||||
/// Bounded stderr tail (unset for PTY-backed commands, which merge streams).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stderr_tail: Option<String>,
|
||||
/// Agent that requested a headless rendezvous, when known.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub requester_agent_id: Option<String>,
|
||||
/// Target agent for a headless rendezvous.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target_agent_id: Option<String>,
|
||||
/// Conversation opened by a headless rendezvous.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
/// Creation timestamp, epoch milliseconds.
|
||||
pub created_at_ms: u64,
|
||||
/// Last update timestamp, epoch milliseconds.
|
||||
@ -3943,6 +4114,8 @@ fn background_state_label(state: BackgroundTaskState) -> &'static str {
|
||||
|
||||
impl From<BackgroundTask> for BackgroundTaskDto {
|
||||
fn from(task: BackgroundTask) -> Self {
|
||||
let (requester_agent_id, target_agent_id, conversation_id) =
|
||||
background_rendezvous_context_labels(&task.kind);
|
||||
let (exit_code, summary, stdout_tail, stderr_tail) = match &task.result {
|
||||
Some(BackgroundTaskResult::Success {
|
||||
exit_code,
|
||||
@ -3984,12 +4157,33 @@ impl From<BackgroundTask> for BackgroundTaskDto {
|
||||
summary,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
requester_agent_id,
|
||||
target_agent_id,
|
||||
conversation_id,
|
||||
created_at_ms: task.created_at_ms,
|
||||
updated_at_ms: task.updated_at_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn background_rendezvous_context_labels(
|
||||
kind: &BackgroundTaskKind,
|
||||
) -> (Option<String>, Option<String>, Option<String>) {
|
||||
match kind {
|
||||
BackgroundTaskKind::HeadlessRendezvous {
|
||||
requester_agent_id,
|
||||
target_agent_id,
|
||||
conversation_id,
|
||||
..
|
||||
} => (
|
||||
requester_agent_id.map(|id| id.to_string()),
|
||||
Some(target_agent_id.to_string()),
|
||||
Some(conversation_id.to_string()),
|
||||
),
|
||||
_ => (None, None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a task-id string (UUID) coming from the frontend.
|
||||
///
|
||||
/// # Errors
|
||||
@ -4038,7 +4232,8 @@ pub struct SpawnBackgroundCommandRequestDto {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use application::McpToolPermissionCatalogue;
|
||||
use domain::{AgentId, ProjectMcpToolPermissions};
|
||||
use domain::mailbox::TicketId;
|
||||
use domain::{AgentId, ConversationId, ProjectMcpToolPermissions};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -4089,4 +4284,78 @@ mod tests {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_task_dto_exposes_rendezvous_context_only_for_headless_rendezvous() {
|
||||
let project_id = ProjectId::from_uuid(Uuid::from_u128(1));
|
||||
let owner = AgentId::from_uuid(Uuid::from_u128(2));
|
||||
let requester = AgentId::from_uuid(Uuid::from_u128(3));
|
||||
let conversation_id = ConversationId::from_uuid(Uuid::from_u128(4));
|
||||
let task = BackgroundTask::new(
|
||||
TaskId::from_uuid(Uuid::from_u128(5)),
|
||||
project_id,
|
||||
owner,
|
||||
BackgroundTaskKind::HeadlessRendezvous {
|
||||
requester_agent_id: Some(requester),
|
||||
target_agent_id: owner,
|
||||
ticket_id: TicketId::from_uuid(Uuid::from_u128(6)),
|
||||
conversation_id,
|
||||
},
|
||||
domain::BackgroundTaskWakePolicy::RecordOnly,
|
||||
100,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let json = serde_json::to_value(BackgroundTaskDto::from(task)).unwrap();
|
||||
|
||||
assert_eq!(json["kind"], "headlessRendezvous");
|
||||
assert_eq!(json["requesterAgentId"], requester.to_string());
|
||||
assert_eq!(json["targetAgentId"], owner.to_string());
|
||||
assert_eq!(json["conversationId"], conversation_id.to_string());
|
||||
|
||||
let command = BackgroundTask::new(
|
||||
TaskId::from_uuid(Uuid::from_u128(7)),
|
||||
project_id,
|
||||
owner,
|
||||
BackgroundTaskKind::Command {
|
||||
label: "cargo test".to_owned(),
|
||||
},
|
||||
domain::BackgroundTaskWakePolicy::RecordOnly,
|
||||
100,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let json = serde_json::to_value(BackgroundTaskDto::from(command)).unwrap();
|
||||
assert!(json.get("requesterAgentId").is_none());
|
||||
assert!(json.get("targetAgentId").is_none());
|
||||
assert!(json.get("conversationId").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_background_task_state_dto_exposes_rendezvous_context() {
|
||||
let requester = AgentId::from_uuid(Uuid::from_u128(11));
|
||||
let target = AgentId::from_uuid(Uuid::from_u128(12));
|
||||
let conversation_id = ConversationId::from_uuid(Uuid::from_u128(13));
|
||||
let state = AgentBackgroundTaskState {
|
||||
task_id: TaskId::from_uuid(Uuid::from_u128(14)),
|
||||
kind: BackgroundTaskKindLabel::HeadlessRendezvous,
|
||||
state: BackgroundTaskState::Completed,
|
||||
exit_code: None,
|
||||
summary: Some("ok".to_owned()),
|
||||
stdout_tail: None,
|
||||
stderr_tail: None,
|
||||
requester_agent_id: Some(requester),
|
||||
target_agent_id: Some(target),
|
||||
conversation_id: Some(conversation_id),
|
||||
created_at_ms: 100,
|
||||
updated_at_ms: 200,
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(AgentBackgroundTaskStateDto::from(state)).unwrap();
|
||||
|
||||
assert_eq!(json["requesterAgentId"], requester.to_string());
|
||||
assert_eq!(json["targetAgentId"], target.to_string());
|
||||
assert_eq!(json["conversationId"], conversation_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::events::{DomainEvent, OrchestrationSource};
|
||||
use domain::events::{DomainEvent, OrchestrationSource, RendezvousContext};
|
||||
use domain::input::AgentLiveness;
|
||||
use domain::model_server::ModelServerLifecycleStatus;
|
||||
use domain::{IssueLinkKind, IssuePriority, IssueStatus};
|
||||
@ -298,6 +298,15 @@ pub enum DomainEventDto {
|
||||
agent_id: String,
|
||||
/// Lightweight event/state label.
|
||||
state: String,
|
||||
/// Agent that requested a headless rendezvous, when known.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
requester_agent_id: Option<String>,
|
||||
/// Target agent for a headless rendezvous.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
target_agent_id: Option<String>,
|
||||
/// Conversation opened by a headless rendezvous.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// An agent inbox queue depth changed.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -725,6 +734,24 @@ fn conversation_party_wire(party: ConversationParty) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn rendezvous_requester_agent_id(rendezvous: &Option<RendezvousContext>) -> Option<String> {
|
||||
rendezvous
|
||||
.as_ref()
|
||||
.and_then(|ctx| ctx.requester_agent_id.map(|id| id.to_string()))
|
||||
}
|
||||
|
||||
fn rendezvous_target_agent_id(rendezvous: &Option<RendezvousContext>) -> Option<String> {
|
||||
rendezvous
|
||||
.as_ref()
|
||||
.map(|ctx| ctx.target_agent_id.to_string())
|
||||
}
|
||||
|
||||
fn rendezvous_conversation_id(rendezvous: &Option<RendezvousContext>) -> Option<String> {
|
||||
rendezvous
|
||||
.as_ref()
|
||||
.map(|ctx| ctx.conversation_id.to_string())
|
||||
}
|
||||
|
||||
impl From<&DomainEvent> for DomainEventDto {
|
||||
fn from(e: &DomainEvent) -> Self {
|
||||
match e {
|
||||
@ -862,6 +889,9 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
task_id: task_id.to_string(),
|
||||
agent_id: owner_agent_id.to_string(),
|
||||
state: "started".to_owned(),
|
||||
requester_agent_id: None,
|
||||
target_agent_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
DomainEvent::BackgroundTaskStateChanged {
|
||||
project_id,
|
||||
@ -873,36 +903,51 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
task_id: task_id.to_string(),
|
||||
agent_id: owner_agent_id.to_string(),
|
||||
state: format!("{state:?}"),
|
||||
requester_agent_id: None,
|
||||
target_agent_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
DomainEvent::BackgroundTaskCompleted {
|
||||
project_id,
|
||||
task_id,
|
||||
owner_agent_id,
|
||||
rendezvous,
|
||||
} => Self::BackgroundTaskChanged {
|
||||
project_id: project_id.to_string(),
|
||||
task_id: task_id.to_string(),
|
||||
agent_id: owner_agent_id.to_string(),
|
||||
state: "completed".to_owned(),
|
||||
requester_agent_id: rendezvous_requester_agent_id(rendezvous),
|
||||
target_agent_id: rendezvous_target_agent_id(rendezvous),
|
||||
conversation_id: rendezvous_conversation_id(rendezvous),
|
||||
},
|
||||
DomainEvent::BackgroundTaskFailed {
|
||||
project_id,
|
||||
task_id,
|
||||
owner_agent_id,
|
||||
rendezvous,
|
||||
} => Self::BackgroundTaskChanged {
|
||||
project_id: project_id.to_string(),
|
||||
task_id: task_id.to_string(),
|
||||
agent_id: owner_agent_id.to_string(),
|
||||
state: "failed".to_owned(),
|
||||
requester_agent_id: rendezvous_requester_agent_id(rendezvous),
|
||||
target_agent_id: rendezvous_target_agent_id(rendezvous),
|
||||
conversation_id: rendezvous_conversation_id(rendezvous),
|
||||
},
|
||||
DomainEvent::BackgroundTaskCancelled {
|
||||
project_id,
|
||||
task_id,
|
||||
owner_agent_id,
|
||||
rendezvous,
|
||||
} => Self::BackgroundTaskChanged {
|
||||
project_id: project_id.to_string(),
|
||||
task_id: task_id.to_string(),
|
||||
agent_id: owner_agent_id.to_string(),
|
||||
state: "cancelled".to_owned(),
|
||||
requester_agent_id: rendezvous_requester_agent_id(rendezvous),
|
||||
target_agent_id: rendezvous_target_agent_id(rendezvous),
|
||||
conversation_id: rendezvous_conversation_id(rendezvous),
|
||||
},
|
||||
DomainEvent::BackgroundTaskCompletionDeliveryPending {
|
||||
project_id,
|
||||
@ -913,6 +958,9 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
task_id: task_id.to_string(),
|
||||
agent_id: owner_agent_id.to_string(),
|
||||
state: "deliveryPending".to_owned(),
|
||||
requester_agent_id: None,
|
||||
target_agent_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
DomainEvent::BackgroundTaskCompletionDelivered {
|
||||
project_id,
|
||||
@ -923,6 +971,9 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
task_id: task_id.to_string(),
|
||||
agent_id: owner_agent_id.to_string(),
|
||||
state: "delivered".to_owned(),
|
||||
requester_agent_id: None,
|
||||
target_agent_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
DomainEvent::AgentInboxQueued { agent_id, depth } => Self::AgentInboxChanged {
|
||||
agent_id: agent_id.to_string(),
|
||||
@ -1205,7 +1256,7 @@ mod tests {
|
||||
use super::*;
|
||||
use domain::ids::AgentId;
|
||||
use domain::mailbox::TicketId;
|
||||
use domain::{LocalModelServerId, ProjectId};
|
||||
use domain::{ConversationId, LocalModelServerId, ProjectId, TaskId};
|
||||
use serde_json::json;
|
||||
|
||||
fn agent(n: u128) -> AgentId {
|
||||
@ -1216,6 +1267,14 @@ mod tests {
|
||||
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn task(n: u128) -> TaskId {
|
||||
TaskId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn conversation(n: u128) -> ConversationId {
|
||||
ConversationId::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 {
|
||||
@ -1348,6 +1407,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_completion_relays_rendezvous_context_to_wire() {
|
||||
let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1));
|
||||
let task_id = task(2);
|
||||
let requester = agent(3);
|
||||
let target = agent(4);
|
||||
let conversation_id = conversation(5);
|
||||
|
||||
let dto = DomainEventDto::from(&DomainEvent::BackgroundTaskCompleted {
|
||||
project_id,
|
||||
task_id,
|
||||
owner_agent_id: target,
|
||||
rendezvous: Some(RendezvousContext {
|
||||
requester_agent_id: Some(requester),
|
||||
target_agent_id: target,
|
||||
conversation_id,
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(&dto).unwrap(),
|
||||
json!({
|
||||
"type": "backgroundTaskChanged",
|
||||
"projectId": project_id.to_string(),
|
||||
"taskId": task_id.to_string(),
|
||||
"agentId": target.to_string(),
|
||||
"state": "completed",
|
||||
"requesterAgentId": requester.to_string(),
|
||||
"targetAgentId": target.to_string(),
|
||||
"conversationId": conversation_id.to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_failure_without_rendezvous_omits_context_fields() {
|
||||
let dto = DomainEventDto::from(&DomainEvent::BackgroundTaskFailed {
|
||||
project_id: ProjectId::from_uuid(uuid::Uuid::from_u128(1)),
|
||||
task_id: task(2),
|
||||
owner_agent_id: agent(3),
|
||||
rendezvous: None,
|
||||
});
|
||||
|
||||
let json = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(json["type"], "backgroundTaskChanged");
|
||||
assert!(json.get("requesterAgentId").is_none());
|
||||
assert!(json.get("targetAgentId").is_none());
|
||||
assert!(json.get("conversationId").is_none());
|
||||
}
|
||||
|
||||
/// LS6 : un `AgentRateLimited` du domaine se relaie en DTO portant le même agent
|
||||
/// et l'heure de reset (époche-ms), et se sérialise en `"agentRateLimited"` avec
|
||||
/// `resetsAtMs` — le fait neutre que le front badge « limité jusqu'à HH:MM ».
|
||||
|
||||
@ -15,24 +15,24 @@ use application::{
|
||||
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
||||
AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive,
|
||||
CancelBackgroundTask, 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, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory,
|
||||
GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches,
|
||||
GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
|
||||
HarvestMemoryFromTurn, HealthUseCase, InspectConversation, InstallPluginFromArchive,
|
||||
InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput,
|
||||
LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues,
|
||||
ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders,
|
||||
ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents,
|
||||
ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
|
||||
LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue,
|
||||
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||
OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
||||
CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal,
|
||||
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
|
||||
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
|
||||
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
|
||||
DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
|
||||
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
||||
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState,
|
||||
GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
|
||||
GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
|
||||
GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation,
|
||||
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent,
|
||||
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
|
||||
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
||||
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
||||
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
||||
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
||||
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
|
||||
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
|
||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
||||
@ -59,8 +59,8 @@ use domain::ports::{
|
||||
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
|
||||
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
||||
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
|
||||
PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore,
|
||||
PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort,
|
||||
ModelArtifactDownloader, PermissionStore, PluginManifestValidator, PluginMcpSupervisor,
|
||||
PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort,
|
||||
RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
|
||||
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
|
||||
WakeError, WakeReason, WindowStateStore,
|
||||
@ -80,19 +80,20 @@ use uuid::Uuid;
|
||||
use infrastructure::{
|
||||
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
||||
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
||||
ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog,
|
||||
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
|
||||
FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore,
|
||||
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore,
|
||||
FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore,
|
||||
FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore,
|
||||
FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer,
|
||||
HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore,
|
||||
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
||||
LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox,
|
||||
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, ReadOnlyRuntimePermissionProbe,
|
||||
RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider,
|
||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner,
|
||||
EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor,
|
||||
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore,
|
||||
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
||||
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
|
||||
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
|
||||
FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore,
|
||||
FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
|
||||
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
|
||||
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess,
|
||||
LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle,
|
||||
PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard,
|
||||
StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider,
|
||||
TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
||||
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
||||
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
@ -940,6 +941,12 @@ pub struct BackendCore {
|
||||
pub save_opencode_provider_profile: Arc<SaveOpenCodeProviderProfile>,
|
||||
/// Static catalogue of OpenCode cloud providers (ticket #92, lot B3).
|
||||
pub list_opencode_providers: Arc<ListOpenCodeProviders>,
|
||||
/// Static curated Claude model catalogue.
|
||||
pub list_claude_models: Arc<ListClaudeModels>,
|
||||
/// Static curated Codex model catalogue.
|
||||
pub list_codex_models: Arc<ListCodexModels>,
|
||||
/// Create a new profile instance from a persisted/reference seed.
|
||||
pub clone_profile_from_seed: Arc<CloneProfileFromSeed>,
|
||||
/// Create a new OpenCode profile instance from the canonical seed.
|
||||
pub clone_opencode_profile_from_seed: Arc<CloneOpenCodeProfileFromSeed>,
|
||||
/// Delete a profile.
|
||||
@ -958,6 +965,8 @@ pub struct BackendCore {
|
||||
pub save_model_server: Arc<SaveModelServer>,
|
||||
/// Deletes local model server configurations when unused.
|
||||
pub delete_model_server: Arc<DeleteModelServer>,
|
||||
/// Deletes managed local model artifacts without deleting server configs.
|
||||
pub delete_model_artifact: Arc<DeleteModelArtifact>,
|
||||
/// The local PTY adapter, kept port-typed so driving adapters can subscribe
|
||||
/// output and route it through their own transport bridge.
|
||||
pub pty_port: Arc<dyn PtyPort>,
|
||||
@ -1468,6 +1477,25 @@ impl BackendCore {
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
));
|
||||
let list_opencode_providers = Arc::new(ListOpenCodeProviders::new());
|
||||
let cli_version_reader = Arc::new(ProcessCliVersionReader::new(Arc::clone(&spawner_port)));
|
||||
let provider_model_catalogue = Arc::new(HttpProviderModelCatalogue::new());
|
||||
let compatibility_matrix = Arc::new(EmbeddedCompatibilityMatrix::with_app_data_dir(
|
||||
app_data_dir.clone(),
|
||||
));
|
||||
let list_claude_models = Arc::new(ListClaudeModels::new(
|
||||
Arc::clone(&cli_version_reader) as Arc<dyn domain::ports::CliVersionReader>,
|
||||
Arc::clone(&provider_model_catalogue) as Arc<dyn domain::ports::ProviderModelCatalogue>,
|
||||
Arc::clone(&compatibility_matrix) as Arc<dyn domain::ports::CompatibilityMatrixSource>,
|
||||
));
|
||||
let list_codex_models = Arc::new(ListCodexModels::new(
|
||||
cli_version_reader as Arc<dyn domain::ports::CliVersionReader>,
|
||||
provider_model_catalogue as Arc<dyn domain::ports::ProviderModelCatalogue>,
|
||||
compatibility_matrix as Arc<dyn domain::ports::CompatibilityMatrixSource>,
|
||||
));
|
||||
let clone_profile_from_seed = Arc::new(CloneProfileFromSeed::new(
|
||||
Arc::clone(&profile_store_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
));
|
||||
let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new(
|
||||
Arc::clone(&profile_store_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
@ -1487,25 +1515,31 @@ impl BackendCore {
|
||||
let model_artifact_downloader = Arc::new(HfModelArtifactDownloader::new(
|
||||
app_data_dir.join("hf-model-artifacts"),
|
||||
));
|
||||
let model_server_probe_port = Arc::new(HttpOpenAiCompatibleProbe::default())
|
||||
as Arc<dyn domain::ports::ModelServerProbe>;
|
||||
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::clone(&model_server_probe_port),
|
||||
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),
|
||||
)
|
||||
.with_model_artifact_downloader(
|
||||
model_artifact_downloader as Arc<dyn domain::ports::ModelArtifactDownloader>,
|
||||
),
|
||||
.with_model_artifact_downloader(Arc::clone(&model_artifact_downloader)
|
||||
as Arc<dyn domain::ports::ModelArtifactDownloader>),
|
||||
);
|
||||
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 model_artifact_downloader_port =
|
||||
Arc::clone(&model_artifact_downloader) as Arc<dyn ModelArtifactDownloader>;
|
||||
let model_artifact_download_tracker = Arc::clone(&ensure_local_model_server)
|
||||
as Arc<dyn application::ModelArtifactDownloadTracker>;
|
||||
let list_model_servers = Arc::new(
|
||||
ListModelServers::new(Arc::clone(&model_server_registry_port))
|
||||
.with_model_artifact_downloader(Arc::clone(&model_artifact_downloader_port))
|
||||
.with_download_tracker(Arc::clone(&model_artifact_download_tracker)),
|
||||
);
|
||||
let save_model_server = Arc::new(SaveModelServer::new(Arc::clone(
|
||||
&model_server_registry_port,
|
||||
)));
|
||||
@ -2361,6 +2395,16 @@ impl BackendCore {
|
||||
Arc::clone(&terminal_sessions),
|
||||
Arc::clone(&structured_sessions),
|
||||
));
|
||||
let delete_model_artifact = Arc::new(DeleteModelArtifact::new(
|
||||
Arc::clone(&model_server_registry_port),
|
||||
Arc::clone(&model_server_probe_port),
|
||||
Arc::clone(&model_artifact_downloader_port),
|
||||
Arc::clone(&model_artifact_download_tracker),
|
||||
Arc::clone(&profile_store_port),
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&contexts_port),
|
||||
Arc::clone(&live_sessions) as Arc<dyn LiveAgentRegistry>,
|
||||
));
|
||||
// Réconciliation du live-state au reboot : repasse en `idle` les lignes
|
||||
// fantômes (working/waiting/blocked) dont la session n'est plus vivante,
|
||||
// selon le MÊME registre de liveness que `GetProjectWorkState`. Provider
|
||||
@ -2661,6 +2705,9 @@ impl BackendCore {
|
||||
save_profile,
|
||||
save_opencode_provider_profile,
|
||||
list_opencode_providers,
|
||||
list_claude_models,
|
||||
list_codex_models,
|
||||
clone_profile_from_seed,
|
||||
clone_opencode_profile_from_seed,
|
||||
delete_profile,
|
||||
configure_profiles,
|
||||
@ -2670,6 +2717,7 @@ impl BackendCore {
|
||||
list_model_servers,
|
||||
save_model_server,
|
||||
delete_model_server,
|
||||
delete_model_artifact,
|
||||
pty_port,
|
||||
terminal_sessions,
|
||||
event_bus,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
//! Domain events published on the [`crate::ports::EventBus`] and relayed to the
|
||||
//! presentation layer (ARCHITECTURE §3.2).
|
||||
|
||||
use crate::conversation::ConversationParty;
|
||||
use crate::conversation::{ConversationId, ConversationParty};
|
||||
use crate::device::DeviceId;
|
||||
use crate::ids::{
|
||||
AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId,
|
||||
@ -14,6 +14,18 @@ use crate::plugin::{PluginId, PluginMcpServerId, PluginVersion};
|
||||
use crate::sprint::{SprintOrder, SprintVersion};
|
||||
use crate::template::TemplateVersion;
|
||||
|
||||
/// Context carried by terminal background-task events when they come from a
|
||||
/// headless inter-agent rendezvous.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RendezvousContext {
|
||||
/// Agent that requested the rendezvous, when known.
|
||||
pub requester_agent_id: Option<AgentId>,
|
||||
/// Target agent that owns the rendezvous conversation.
|
||||
pub target_agent_id: AgentId,
|
||||
/// Conversation opened by the target turn.
|
||||
pub conversation_id: ConversationId,
|
||||
}
|
||||
|
||||
/// Which entry door a processed orchestration request arrived through.
|
||||
///
|
||||
/// IdeA exposes the *same* [`crate::OrchestratorService::dispatch`] behind two
|
||||
@ -142,6 +154,8 @@ pub enum DomainEvent {
|
||||
task_id: TaskId,
|
||||
/// The agent that owns completion delivery.
|
||||
owner_agent_id: AgentId,
|
||||
/// Rendezvous context when this terminal task is a headless ask.
|
||||
rendezvous: Option<RendezvousContext>,
|
||||
},
|
||||
/// A first-class background task failed.
|
||||
BackgroundTaskFailed {
|
||||
@ -151,6 +165,8 @@ pub enum DomainEvent {
|
||||
task_id: TaskId,
|
||||
/// The agent that owns completion delivery.
|
||||
owner_agent_id: AgentId,
|
||||
/// Rendezvous context when this terminal task is a headless ask.
|
||||
rendezvous: Option<RendezvousContext>,
|
||||
},
|
||||
/// A first-class background task was cancelled.
|
||||
BackgroundTaskCancelled {
|
||||
@ -160,6 +176,8 @@ pub enum DomainEvent {
|
||||
task_id: TaskId,
|
||||
/// The agent that owns completion delivery.
|
||||
owner_agent_id: AgentId,
|
||||
/// Rendezvous context when this terminal task is a headless ask.
|
||||
rendezvous: Option<RendezvousContext>,
|
||||
},
|
||||
/// A first-class background task has a terminal result not yet delivered.
|
||||
BackgroundTaskCompletionDeliveryPending {
|
||||
|
||||
@ -51,6 +51,7 @@ pub mod markdown;
|
||||
pub mod mcp_tool_permissions;
|
||||
pub mod memory;
|
||||
pub mod memory_harvest;
|
||||
pub mod model_catalogue;
|
||||
pub mod model_server;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
@ -75,6 +76,7 @@ mod validation;
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub use error::DomainError;
|
||||
pub use events::RendezvousContext;
|
||||
|
||||
pub use ids::{
|
||||
AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RuntimeAgentKey,
|
||||
@ -167,6 +169,11 @@ pub use memory_harvest::{
|
||||
MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS,
|
||||
};
|
||||
|
||||
pub use model_catalogue::{
|
||||
evaluate_compatibility, CliVersion, CompatibilityMatrix, ModelCatalogSource,
|
||||
ModelCatalogueError, ModelCompatibility,
|
||||
};
|
||||
|
||||
pub use model_server::{
|
||||
validate_free_args, ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef,
|
||||
LocalModelServerConfig, LocalModelServerKind, ModelPath, ModelServerEndpoint,
|
||||
@ -226,16 +233,17 @@ pub use ports::{
|
||||
AgentContextStore, AgentRuntime, AgentToolPolicyStore, AssistantContextError,
|
||||
AssistantContextProvider, BackgroundCompletionStream, BackgroundTaskCompletion,
|
||||
BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec,
|
||||
BackgroundTaskStore, Clock, ContextInjectionPlan, DirEntry, Embedder, EmbedderEnvInspector,
|
||||
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
||||
EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo,
|
||||
GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore,
|
||||
IssueStoreError, LiveStateStore, LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery,
|
||||
MemoryRecall, MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
||||
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
|
||||
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
|
||||
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError,
|
||||
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
|
||||
BackgroundTaskStore, CliVersionReader, Clock, CompatibilityMatrixSource, ContextInjectionPlan,
|
||||
DirEntry, Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError,
|
||||
EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream,
|
||||
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
|
||||
IdGenerator, IssueNumberAllocator, IssueStore, IssueStoreError, LiveStateStore, LocalPath,
|
||||
McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore,
|
||||
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
|
||||
Output, OutputStream, PermissionStore, PluginManifestBytes, PluginManifestError,
|
||||
PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
|
||||
PluginRegistryError, PluginRegistryStore, PluginStoreError, PreparedContext, ProcessError,
|
||||
ProcessSpawner, ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle,
|
||||
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe,
|
||||
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
|
||||
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore,
|
||||
|
||||
216
crates/domain/src/model_catalogue.rs
Normal file
216
crates/domain/src/model_catalogue.rs
Normal file
@ -0,0 +1,216 @@
|
||||
//! Pure model-catalogue compatibility types.
|
||||
|
||||
use core::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::profile::StructuredAdapter;
|
||||
|
||||
/// Parsed CLI version used for local compatibility checks.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CliVersion {
|
||||
/// Original version string reported by the CLI.
|
||||
pub raw: String,
|
||||
parts: Vec<u64>,
|
||||
}
|
||||
|
||||
impl CliVersion {
|
||||
/// Parses a version from a string containing at least one digit.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`ModelCatalogueError::InvalidVersion`] when no numeric version
|
||||
/// segment can be found.
|
||||
pub fn parse(raw: impl Into<String>) -> Result<Self, ModelCatalogueError> {
|
||||
let raw = raw.into();
|
||||
let start = raw
|
||||
.char_indices()
|
||||
.find_map(|(idx, ch)| ch.is_ascii_digit().then_some(idx))
|
||||
.ok_or_else(|| ModelCatalogueError::InvalidVersion(raw.clone()))?;
|
||||
let version = raw[start..]
|
||||
.chars()
|
||||
.take_while(|ch| ch.is_ascii_digit() || *ch == '.')
|
||||
.collect::<String>();
|
||||
let parts = version
|
||||
.split('.')
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(str::parse::<u64>)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|_| ModelCatalogueError::InvalidVersion(raw.clone()))?;
|
||||
if parts.is_empty() {
|
||||
return Err(ModelCatalogueError::InvalidVersion(raw));
|
||||
}
|
||||
Ok(Self { raw, parts })
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for CliVersion {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for CliVersion {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
let max_len = self.parts.len().max(other.parts.len());
|
||||
for idx in 0..max_len {
|
||||
match self
|
||||
.parts
|
||||
.get(idx)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.cmp(&other.parts.get(idx).copied().unwrap_or(0))
|
||||
{
|
||||
Ordering::Equal => {}
|
||||
ordering => return ordering,
|
||||
}
|
||||
}
|
||||
Ordering::Equal
|
||||
}
|
||||
}
|
||||
|
||||
/// Compatibility state between a local CLI version and a model id.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ModelCompatibility {
|
||||
/// The known minimum CLI version is satisfied.
|
||||
Compatible,
|
||||
/// The CLI version is absent, or the model is not covered by the matrix.
|
||||
Unknown,
|
||||
/// The model is covered by the matrix but appears newer than the local CLI.
|
||||
LikelyTooRecent,
|
||||
}
|
||||
|
||||
/// Origin of a model-catalogue entry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ModelCatalogSource {
|
||||
/// Curated static seed maintained by IdeA.
|
||||
Catalogue,
|
||||
/// Best-effort provider API discovery.
|
||||
Provider,
|
||||
}
|
||||
|
||||
/// Matrix mapping adapter/model ids to their minimum known CLI version.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompatibilityMatrix {
|
||||
/// Matrix schema/data version.
|
||||
pub version: u32,
|
||||
/// Claude Code entries keyed by model id.
|
||||
#[serde(default)]
|
||||
pub claude: HashMap<String, String>,
|
||||
/// Codex CLI entries keyed by model id.
|
||||
#[serde(default)]
|
||||
pub codex: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl CompatibilityMatrix {
|
||||
/// Looks up the minimum CLI version for an adapter/model pair.
|
||||
#[must_use]
|
||||
pub fn minimum_version(&self, adapter: StructuredAdapter, model_id: &str) -> Option<&str> {
|
||||
match adapter {
|
||||
StructuredAdapter::Claude => self.claude.get(model_id).map(String::as_str),
|
||||
StructuredAdapter::Codex => self.codex.get(model_id).map(String::as_str),
|
||||
StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates local compatibility using only pure matrix data.
|
||||
#[must_use]
|
||||
pub fn evaluate_compatibility(
|
||||
matrix: &CompatibilityMatrix,
|
||||
adapter: StructuredAdapter,
|
||||
model_id: &str,
|
||||
cli_version: Option<&CliVersion>,
|
||||
) -> ModelCompatibility {
|
||||
let Some(cli_version) = cli_version else {
|
||||
return ModelCompatibility::Unknown;
|
||||
};
|
||||
let Some(minimum) = matrix.minimum_version(adapter, model_id) else {
|
||||
return ModelCompatibility::Unknown;
|
||||
};
|
||||
let Ok(minimum) = CliVersion::parse(minimum.to_owned()) else {
|
||||
return ModelCompatibility::Unknown;
|
||||
};
|
||||
if &minimum <= cli_version {
|
||||
ModelCompatibility::Compatible
|
||||
} else {
|
||||
ModelCompatibility::LikelyTooRecent
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors from pure model-catalogue parsing.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ModelCatalogueError {
|
||||
/// Version strings must contain at least one numeric segment.
|
||||
#[error("invalid CLI version: {0}")]
|
||||
InvalidVersion(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn matrix() -> CompatibilityMatrix {
|
||||
CompatibilityMatrix {
|
||||
version: 1,
|
||||
claude: HashMap::from([("claude-sonnet-5".to_owned(), "1.2.0".to_owned())]),
|
||||
codex: HashMap::from([("gpt-5-codex".to_owned(), "0.44.0".to_owned())]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_versions_compare_by_numeric_parts() {
|
||||
assert!(CliVersion::parse("codex 0.10.0").unwrap() > CliVersion::parse("0.9.9").unwrap());
|
||||
assert_eq!(
|
||||
CliVersion::parse("1.2")
|
||||
.unwrap()
|
||||
.cmp(&CliVersion::parse("1.2.0").unwrap()),
|
||||
Ordering::Equal
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_is_unknown_without_version_or_matrix_entry() {
|
||||
let matrix = matrix();
|
||||
assert_eq!(
|
||||
evaluate_compatibility(&matrix, StructuredAdapter::Codex, "gpt-5-codex", None),
|
||||
ModelCompatibility::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_compatibility(
|
||||
&matrix,
|
||||
StructuredAdapter::Codex,
|
||||
"future-model",
|
||||
Some(&CliVersion::parse("999.0.0").unwrap())
|
||||
),
|
||||
ModelCompatibility::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_detects_supported_and_too_recent_models() {
|
||||
let matrix = matrix();
|
||||
assert_eq!(
|
||||
evaluate_compatibility(
|
||||
&matrix,
|
||||
StructuredAdapter::Codex,
|
||||
"gpt-5-codex",
|
||||
Some(&CliVersion::parse("0.44.0").unwrap())
|
||||
),
|
||||
ModelCompatibility::Compatible
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_compatibility(
|
||||
&matrix,
|
||||
StructuredAdapter::Claude,
|
||||
"claude-sonnet-5",
|
||||
Some(&CliVersion::parse("1.1.9").unwrap())
|
||||
),
|
||||
ModelCompatibility::LikelyTooRecent
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -45,6 +45,7 @@ use crate::issue::{
|
||||
use crate::markdown::MarkdownDoc;
|
||||
use crate::mcp_tool_permissions::ProjectMcpToolPermissions;
|
||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||
use crate::model_catalogue::{CliVersion, CompatibilityMatrix};
|
||||
use crate::model_server::{
|
||||
HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus,
|
||||
};
|
||||
@ -54,7 +55,7 @@ use crate::plugin::{
|
||||
PluginMcpStatusSet, PluginPackageRef, PluginRegistry, RelativePath, RemovalOutcome,
|
||||
StagedPluginPackage,
|
||||
};
|
||||
use crate::profile::{AgentProfile, EmbedderProfile};
|
||||
use crate::profile::{AgentProfile, EmbedderProfile, StructuredAdapter};
|
||||
use crate::project::{Project, ProjectPath};
|
||||
use crate::remote::RemoteKind;
|
||||
use crate::skill::{Skill, SkillScope};
|
||||
@ -1245,6 +1246,37 @@ pub trait ProcessSpawner: Send + Sync {
|
||||
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
|
||||
}
|
||||
|
||||
/// Read a local structured CLI version using only the allowed `--version` probe.
|
||||
#[async_trait]
|
||||
pub trait CliVersionReader: Send + Sync {
|
||||
/// Best-effort local CLI version lookup.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns a string suitable for non-blocking catalogue warnings.
|
||||
async fn read_cli_version(
|
||||
&self,
|
||||
adapter: StructuredAdapter,
|
||||
) -> Result<Option<CliVersion>, String>;
|
||||
}
|
||||
|
||||
/// Best-effort provider API model catalogue.
|
||||
#[async_trait]
|
||||
pub trait ProviderModelCatalogue: Send + Sync {
|
||||
/// Lists provider model ids for an adapter. `Ok(Vec::new())` means no key or
|
||||
/// unsupported provider and is not a warning-worthy failure.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns a string suitable for non-blocking catalogue warnings.
|
||||
async fn list_provider_models(&self, adapter: StructuredAdapter)
|
||||
-> Result<Vec<String>, String>;
|
||||
}
|
||||
|
||||
/// Source of the versioned compatibility matrix.
|
||||
pub trait CompatibilityMatrixSource: Send + Sync {
|
||||
/// Returns matrix data and any fallback warnings.
|
||||
fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec<String>);
|
||||
}
|
||||
|
||||
/// Probe readiness of an OpenAI-compatible model server.
|
||||
#[async_trait]
|
||||
pub trait ModelServerProbe: Send + Sync {
|
||||
@ -1279,6 +1311,22 @@ pub struct ModelArtifactResolution {
|
||||
pub cache_hit: bool,
|
||||
}
|
||||
|
||||
/// Cache state for a model artifact managed by IdeA.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ModelArtifactState {
|
||||
/// Artifact source is not managed by the downloader.
|
||||
NotManaged,
|
||||
/// Managed artifact is not present in cache.
|
||||
Missing,
|
||||
/// Managed artifact is present in cache.
|
||||
Downloaded {
|
||||
/// Local path used to launch the model.
|
||||
path: ModelPath,
|
||||
/// Total on-disk bytes when known.
|
||||
size_bytes: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Cooperative cancellation token for model artifact resolution.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ModelArtifactCancel {
|
||||
@ -1307,6 +1355,15 @@ impl ModelArtifactCancel {
|
||||
/// Resolves or downloads a model artifact before starting a model server.
|
||||
#[async_trait]
|
||||
pub trait ModelArtifactDownloader: Send + Sync {
|
||||
/// Returns the current cache state for a Hugging Face model reference.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] when the cache cannot be inspected.
|
||||
async fn hf_model_state(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
) -> Result<ModelArtifactState, ModelServerError>;
|
||||
|
||||
/// Resolves a Hugging Face model to a local artifact path.
|
||||
///
|
||||
/// # Errors
|
||||
@ -1317,6 +1374,14 @@ pub trait ModelArtifactDownloader: Send + Sync {
|
||||
progress: Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
|
||||
cancel: ModelArtifactCancel,
|
||||
) -> Result<ModelArtifactResolution, ModelServerError>;
|
||||
|
||||
/// Deletes the cached artifact for a Hugging Face model reference.
|
||||
///
|
||||
/// Deleting a missing artifact is a successful no-op.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`ModelServerError`] when deletion fails.
|
||||
async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError>;
|
||||
}
|
||||
|
||||
/// Manages local long-lived child processes.
|
||||
|
||||
@ -26,6 +26,7 @@ pub mod input;
|
||||
pub mod inspector;
|
||||
pub mod issues;
|
||||
pub mod mailbox;
|
||||
pub mod model_catalogue;
|
||||
pub mod model_server;
|
||||
pub mod orchestrator;
|
||||
pub mod pair_attempt_limiter;
|
||||
@ -68,6 +69,9 @@ pub use inspector::{
|
||||
};
|
||||
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
||||
pub use mailbox::InMemoryMailbox;
|
||||
pub use model_catalogue::{
|
||||
EmbeddedCompatibilityMatrix, HttpProviderModelCatalogue, ProcessCliVersionReader,
|
||||
};
|
||||
pub use model_server::{
|
||||
FsModelServerRegistry, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, LlamaCppRuntime,
|
||||
LocalManagedProcess,
|
||||
|
||||
331
crates/infrastructure/src/model_catalogue.rs
Normal file
331
crates/infrastructure/src/model_catalogue.rs
Normal file
@ -0,0 +1,331 @@
|
||||
//! Concrete adapters for structured model-catalogue enrichment.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::model_catalogue::{CliVersion, CompatibilityMatrix};
|
||||
use domain::ports::{
|
||||
CliVersionReader, CompatibilityMatrixSource, ProcessSpawner, ProviderModelCatalogue, SpawnSpec,
|
||||
};
|
||||
use domain::profile::StructuredAdapter;
|
||||
use domain::project::ProjectPath;
|
||||
use serde::Deserialize;
|
||||
|
||||
const VERSION_TIMEOUT: Duration = Duration::from_millis(800);
|
||||
const PROVIDER_TIMEOUT: Duration = Duration::from_millis(1_500);
|
||||
const EMBEDDED_MATRIX: &str = include_str!("model_compatibility_matrix.json");
|
||||
|
||||
/// Reads local CLI versions through `codex --version` / `claude --version`.
|
||||
#[derive(Clone)]
|
||||
pub struct ProcessCliVersionReader {
|
||||
spawner: Arc<dyn ProcessSpawner>,
|
||||
}
|
||||
|
||||
impl ProcessCliVersionReader {
|
||||
/// Builds the adapter from the process-spawner port.
|
||||
#[must_use]
|
||||
pub fn new(spawner: Arc<dyn ProcessSpawner>) -> Self {
|
||||
Self { spawner }
|
||||
}
|
||||
|
||||
fn spec(adapter: StructuredAdapter) -> Option<SpawnSpec> {
|
||||
let command = match adapter {
|
||||
StructuredAdapter::Claude => "claude",
|
||||
StructuredAdapter::Codex => "codex",
|
||||
StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => return None,
|
||||
};
|
||||
Some(SpawnSpec {
|
||||
command: command.to_owned(),
|
||||
args: vec!["--version".to_owned()],
|
||||
cwd: ProjectPath::new("/").expect("root project path is valid"),
|
||||
env: Vec::new(),
|
||||
context_plan: None,
|
||||
sandbox: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CliVersionReader for ProcessCliVersionReader {
|
||||
async fn read_cli_version(
|
||||
&self,
|
||||
adapter: StructuredAdapter,
|
||||
) -> Result<Option<CliVersion>, String> {
|
||||
let Some(spec) = Self::spec(adapter) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let command = spec.command.clone();
|
||||
let output = tokio::time::timeout(VERSION_TIMEOUT, self.spawner.run(spec))
|
||||
.await
|
||||
.map_err(|_| format!("{command} --version timed out"))?
|
||||
.map_err(|e| format!("{command} --version failed: {e}"))?;
|
||||
if output.status.code != Some(0) {
|
||||
return Err(format!(
|
||||
"{command} --version exited with {:?}",
|
||||
output.status.code
|
||||
));
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_owned()
|
||||
.if_empty_then(|| String::from_utf8_lossy(&output.stderr).trim().to_owned());
|
||||
if text.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
CliVersion::parse(text)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("{command} --version was not parseable: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
trait EmptyStringExt {
|
||||
fn if_empty_then(self, fallback: impl FnOnce() -> String) -> String;
|
||||
}
|
||||
|
||||
impl EmptyStringExt for String {
|
||||
fn if_empty_then(self, fallback: impl FnOnce() -> String) -> String {
|
||||
if self.is_empty() {
|
||||
fallback()
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider HTTP catalogue using existing API keys from the process environment.
|
||||
#[derive(Clone)]
|
||||
pub struct HttpProviderModelCatalogue {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl HttpProviderModelCatalogue {
|
||||
/// Builds the adapter.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_openai(&self, key: String) -> Result<Vec<String>, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct Response {
|
||||
data: Vec<Model>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Model {
|
||||
id: String,
|
||||
}
|
||||
|
||||
let response = tokio::time::timeout(
|
||||
PROVIDER_TIMEOUT,
|
||||
self.client
|
||||
.get("https://api.openai.com/v1/models")
|
||||
.bearer_auth(key)
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "OpenAI model catalogue timed out".to_owned())?
|
||||
.map_err(|e| format!("OpenAI model catalogue unavailable: {e}"))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"OpenAI model catalogue returned HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
let parsed = response
|
||||
.json::<Response>()
|
||||
.await
|
||||
.map_err(|e| format!("OpenAI model catalogue parse failed: {e}"))?;
|
||||
Ok(dedup_non_empty(
|
||||
parsed.data.into_iter().map(|model| model.id),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_anthropic(&self, key: String) -> Result<Vec<String>, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct Response {
|
||||
data: Vec<Model>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Model {
|
||||
id: String,
|
||||
}
|
||||
|
||||
let response = tokio::time::timeout(
|
||||
PROVIDER_TIMEOUT,
|
||||
self.client
|
||||
.get("https://api.anthropic.com/v1/models")
|
||||
.header("x-api-key", key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Anthropic model catalogue timed out".to_owned())?
|
||||
.map_err(|e| format!("Anthropic model catalogue unavailable: {e}"))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Anthropic model catalogue returned HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
let parsed = response
|
||||
.json::<Response>()
|
||||
.await
|
||||
.map_err(|e| format!("Anthropic model catalogue parse failed: {e}"))?;
|
||||
Ok(dedup_non_empty(
|
||||
parsed.data.into_iter().map(|model| model.id),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HttpProviderModelCatalogue {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderModelCatalogue for HttpProviderModelCatalogue {
|
||||
async fn list_provider_models(
|
||||
&self,
|
||||
adapter: StructuredAdapter,
|
||||
) -> Result<Vec<String>, String> {
|
||||
match adapter {
|
||||
StructuredAdapter::Codex => match env::var("OPENAI_API_KEY") {
|
||||
Ok(key) if !key.trim().is_empty() => self.list_openai(key).await,
|
||||
_ => Ok(Vec::new()),
|
||||
},
|
||||
StructuredAdapter::Claude => match env::var("ANTHROPIC_API_KEY") {
|
||||
Ok(key) if !key.trim().is_empty() => self.list_anthropic(key).await,
|
||||
_ => Ok(Vec::new()),
|
||||
},
|
||||
StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dedup_non_empty(values: impl Iterator<Item = String>) -> Vec<String> {
|
||||
values
|
||||
.map(|value| value.trim().to_owned())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compatibility matrix source backed by an embedded JSON seed and optional
|
||||
/// app-data override.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmbeddedCompatibilityMatrix {
|
||||
override_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl EmbeddedCompatibilityMatrix {
|
||||
/// Builds a matrix source with no override.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
override_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a matrix source reading `model-compat.json` from the app data dir
|
||||
/// before falling back to the embedded seed.
|
||||
#[must_use]
|
||||
pub fn with_app_data_dir(app_data_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
override_path: Some(app_data_dir.into().join("model-compat.json")),
|
||||
}
|
||||
}
|
||||
|
||||
fn embedded() -> CompatibilityMatrix {
|
||||
serde_json::from_str(EMBEDDED_MATRIX).expect("embedded compatibility matrix is valid")
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EmbeddedCompatibilityMatrix {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CompatibilityMatrixSource for EmbeddedCompatibilityMatrix {
|
||||
fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec<String>) {
|
||||
let Some(path) = &self.override_path else {
|
||||
return (Self::embedded(), Vec::new());
|
||||
};
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(raw) => match serde_json::from_str::<CompatibilityMatrix>(&raw) {
|
||||
Ok(matrix) => (matrix, Vec::new()),
|
||||
Err(e) => (
|
||||
Self::embedded(),
|
||||
vec![format!(
|
||||
"model compatibility override ignored because it is invalid: {e}"
|
||||
)],
|
||||
),
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (Self::embedded(), Vec::new()),
|
||||
Err(e) => (
|
||||
Self::embedded(),
|
||||
vec![format!(
|
||||
"model compatibility override ignored because it is unreadable: {e}"
|
||||
)],
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::ports::{ExitStatus, Output, ProcessError};
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct RecordingSpawner {
|
||||
specs: Mutex<Vec<SpawnSpec>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProcessSpawner for RecordingSpawner {
|
||||
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError> {
|
||||
self.specs.lock().unwrap().push(spec);
|
||||
Ok(Output {
|
||||
status: ExitStatus { code: Some(0) },
|
||||
stdout: b"codex-cli 0.45.1\n".to_vec(),
|
||||
stderr: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_version_reader_runs_only_version_probe() {
|
||||
let spawner = Arc::new(RecordingSpawner {
|
||||
specs: Mutex::new(Vec::new()),
|
||||
});
|
||||
let reader = ProcessCliVersionReader::new(spawner.clone());
|
||||
let version = reader
|
||||
.read_cli_version(StructuredAdapter::Codex)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(version.raw, "codex-cli 0.45.1");
|
||||
let specs = spawner.specs.lock().unwrap();
|
||||
assert_eq!(specs.len(), 1);
|
||||
assert_eq!(specs[0].command, "codex");
|
||||
assert_eq!(specs[0].args, vec!["--version"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_matrix_is_valid() {
|
||||
let (matrix, warnings) = EmbeddedCompatibilityMatrix::new().compatibility_matrix();
|
||||
assert!(warnings.is_empty());
|
||||
assert!(matrix.codex.contains_key("gpt-5-codex"));
|
||||
assert!(matrix.claude.contains_key("claude-sonnet-5"));
|
||||
}
|
||||
}
|
||||
13
crates/infrastructure/src/model_compatibility_matrix.json
Normal file
13
crates/infrastructure/src/model_compatibility_matrix.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"claude": {
|
||||
"claude-sonnet-5": "1.0.0",
|
||||
"claude-opus-4-8": "1.0.0",
|
||||
"claude-haiku-4-5-20251001": "1.0.0"
|
||||
},
|
||||
"codex": {
|
||||
"gpt-5-codex": "0.1.0",
|
||||
"gpt-5": "0.1.0",
|
||||
"gpt-5-mini": "0.1.0"
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@ -10,6 +10,7 @@ use futures_util::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use domain::model_server::{
|
||||
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||
@ -17,9 +18,9 @@ use domain::model_server::{
|
||||
};
|
||||
use domain::ports::{
|
||||
FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel, ModelArtifactDownloader,
|
||||
ModelArtifactProgress, ModelArtifactResolution, ModelServerArgv, ModelServerError,
|
||||
ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath,
|
||||
SpawnSpec,
|
||||
ModelArtifactProgress, ModelArtifactResolution, ModelArtifactState, ModelServerArgv,
|
||||
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
|
||||
RemotePath, SpawnSpec,
|
||||
};
|
||||
use domain::{LocalModelServerId, ProjectPath, StopPolicy};
|
||||
|
||||
@ -76,6 +77,7 @@ fn is_ready(result: Result<reqwest::Response, reqwest::Error>) -> bool {
|
||||
pub struct HfModelArtifactDownloader {
|
||||
cache_dir: PathBuf,
|
||||
client: reqwest::Client,
|
||||
repo_locks: Arc<Mutex<HashMap<String, Arc<AsyncMutex<()>>>>>,
|
||||
}
|
||||
|
||||
impl HfModelArtifactDownloader {
|
||||
@ -85,6 +87,7 @@ impl HfModelArtifactDownloader {
|
||||
Self {
|
||||
cache_dir: cache_dir.into(),
|
||||
client: reqwest::Client::new(),
|
||||
repo_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@ -149,6 +152,62 @@ impl HfModelArtifactDownloader {
|
||||
std::fs::write(manifest_path, json)
|
||||
}
|
||||
|
||||
fn lock_for(&self, repo: &HfModelRef) -> Arc<AsyncMutex<()>> {
|
||||
let mut locks = self.repo_locks.lock().expect("repo locks mutex poisoned");
|
||||
Arc::clone(
|
||||
locks
|
||||
.entry(repo.as_str().to_owned())
|
||||
.or_insert_with(|| Arc::new(AsyncMutex::new(()))),
|
||||
)
|
||||
}
|
||||
|
||||
fn cached_state(&self, repo: &HfModelRef) -> Result<ModelArtifactState, ModelServerError> {
|
||||
let merged_path = self.cache_path_for(repo);
|
||||
if merged_path.is_file() {
|
||||
return Ok(ModelArtifactState::Downloaded {
|
||||
size_bytes: Some(file_size(&merged_path)?),
|
||||
path: model_path_from_pathbuf(merged_path)?,
|
||||
});
|
||||
}
|
||||
if let Some(paths) = self.cached_shard_set(repo) {
|
||||
if let Some(first) = paths.first() {
|
||||
return Ok(ModelArtifactState::Downloaded {
|
||||
size_bytes: Some(paths_size(&paths)?),
|
||||
path: model_path_from_pathbuf(first.clone())?,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(ModelArtifactState::Missing)
|
||||
}
|
||||
|
||||
fn delete_cached(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
|
||||
let merged_path = self.cache_path_for(repo);
|
||||
if merged_path.is_file() {
|
||||
remove_file_if_exists(&merged_path)?;
|
||||
}
|
||||
|
||||
let manifest_path = self.manifest_path_for(repo);
|
||||
if let Ok(raw) = std::fs::read(&manifest_path) {
|
||||
let manifest: ShardManifest =
|
||||
serde_json::from_slice(&raw).map_err(|e| ModelServerError::Store(e.to_string()))?;
|
||||
let dir = self.cache_dir_for(repo);
|
||||
for filename in manifest.files {
|
||||
remove_file_if_exists(&dir.join(filename))?;
|
||||
}
|
||||
remove_file_if_exists(&manifest_path)?;
|
||||
}
|
||||
|
||||
let repo_dir = self.cache_dir_for(repo);
|
||||
if repo_dir.is_dir()
|
||||
&& std::fs::read_dir(&repo_dir)
|
||||
.map(is_empty_dir)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
std::fs::remove_dir(&repo_dir).map_err(|e| ModelServerError::Store(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_remote_filenames(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
@ -189,12 +248,23 @@ struct ShardManifest {
|
||||
|
||||
#[async_trait]
|
||||
impl ModelArtifactDownloader for HfModelArtifactDownloader {
|
||||
async fn hf_model_state(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
) -> Result<ModelArtifactState, ModelServerError> {
|
||||
let lock = self.lock_for(repo);
|
||||
let _guard = lock.lock().await;
|
||||
self.cached_state(repo)
|
||||
}
|
||||
|
||||
async fn resolve_hf_model(
|
||||
&self,
|
||||
repo: &HfModelRef,
|
||||
progress: std::sync::Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
|
||||
cancel: ModelArtifactCancel,
|
||||
) -> Result<ModelArtifactResolution, ModelServerError> {
|
||||
let lock = self.lock_for(repo);
|
||||
let _guard = lock.lock().await;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ModelServerError::Cancelled);
|
||||
}
|
||||
@ -300,6 +370,12 @@ impl ModelArtifactDownloader for HfModelArtifactDownloader {
|
||||
cache_hit: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
|
||||
let lock = self.lock_for(repo);
|
||||
let _guard = lock.lock().await;
|
||||
self.delete_cached(repo)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@ -409,6 +485,31 @@ fn model_path_from_pathbuf(path: PathBuf) -> Result<ModelPath, ModelServerError>
|
||||
.map_err(|e| ModelServerError::Invalid(e.to_string()))
|
||||
}
|
||||
|
||||
fn file_size(path: &Path) -> Result<u64, ModelServerError> {
|
||||
std::fs::metadata(path)
|
||||
.map(|metadata| metadata.len())
|
||||
.map_err(|e| ModelServerError::Store(e.to_string()))
|
||||
}
|
||||
|
||||
fn paths_size(paths: &[PathBuf]) -> Result<u64, ModelServerError> {
|
||||
paths
|
||||
.iter()
|
||||
.map(|path| file_size(path))
|
||||
.try_fold(0_u64, |acc, size| size.map(|size| acc.saturating_add(size)))
|
||||
}
|
||||
|
||||
fn remove_file_if_exists(path: &Path) -> Result<(), ModelServerError> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(ModelServerError::Store(err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty_dir(entries: std::fs::ReadDir) -> bool {
|
||||
entries.into_iter().next().is_none()
|
||||
}
|
||||
|
||||
/// Builds `llama-server` argv without shell interpolation.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct LlamaCppRuntime;
|
||||
|
||||
@ -8,8 +8,8 @@ use domain::model_server::{
|
||||
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, StopPolicy,
|
||||
};
|
||||
use domain::ports::{
|
||||
FileSystem, ModelArtifactCancel, ModelArtifactDownloader, ModelServerRegistry,
|
||||
ModelServerRuntime, RemotePath,
|
||||
FileSystem, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactState,
|
||||
ModelServerRegistry, ModelServerRuntime, RemotePath,
|
||||
};
|
||||
use domain::LocalModelServerId;
|
||||
use infrastructure::{
|
||||
@ -227,3 +227,41 @@ async fn hf_model_artifact_downloader_resolves_deterministic_local_cache_hit_wit
|
||||
std::path::Path::new("Qwen--Qwen3-Coder").join("Q4_K_M.gguf")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hf_model_artifact_downloader_reports_downloaded_cache_state() {
|
||||
let tmp = TempDir::new();
|
||||
let downloader = HfModelArtifactDownloader::new(tmp.path());
|
||||
let repo = HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap();
|
||||
let path = downloader.cache_path_for(&repo);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, b"gguf").unwrap();
|
||||
|
||||
let state = downloader.hf_model_state(&repo).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
state,
|
||||
ModelArtifactState::Downloaded {
|
||||
path: ModelPath::new(path.to_string_lossy()).unwrap(),
|
||||
size_bytes: Some(4),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hf_model_artifact_downloader_deletes_merged_cache_without_config_side_effects() {
|
||||
let tmp = TempDir::new();
|
||||
let downloader = HfModelArtifactDownloader::new(tmp.path());
|
||||
let repo = HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap();
|
||||
let path = downloader.cache_path_for(&repo);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, b"gguf").unwrap();
|
||||
|
||||
downloader.delete_hf_model(&repo).await.unwrap();
|
||||
|
||||
assert!(!path.exists());
|
||||
assert_eq!(
|
||||
downloader.hf_model_state(&repo).await.unwrap(),
|
||||
ModelArtifactState::Missing
|
||||
);
|
||||
}
|
||||
|
||||
@ -79,18 +79,18 @@ use backend::dto::{
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
|
||||
MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
|
||||
ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
|
||||
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||
ReadConversationPageRequestDto, RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto,
|
||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
||||
SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto,
|
||||
SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto,
|
||||
SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
||||
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||
TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto, TicketCreateRequestDto,
|
||||
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
|
||||
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
|
||||
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
||||
ResolveAgentPermissionsRequestDto, ResolveAgentSystemPermissionsRequestDto,
|
||||
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SkillDto, SkillListDto,
|
||||
SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto,
|
||||
SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
|
||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||
TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto,
|
||||
TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto,
|
||||
TicketListPageInput, TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
|
||||
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
|
||||
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||
@ -2353,6 +2353,8 @@ async fn invoke(
|
||||
"list_profiles" => invoke_list_profiles(&state.app).await,
|
||||
"save_profile" => invoke_save_profile(&request.args, &state.app).await,
|
||||
"list_opencode_providers" => invoke_list_opencode_providers(&state.app),
|
||||
"list_claude_models" => invoke_list_claude_models(&state.app).await,
|
||||
"list_codex_models" => invoke_list_codex_models(&state.app).await,
|
||||
"save_opencode_provider_profile" => {
|
||||
invoke_save_opencode_provider_profile(&request.args, &state.app).await
|
||||
}
|
||||
@ -2606,6 +2608,16 @@ fn invoke_list_opencode_providers(state: &BackendCore) -> Result<Value, ErrorDto
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_list_claude_models(state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||
let output: ProfileModelCatalogDto = state.list_claude_models.execute().await.into();
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_list_codex_models(state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||
let output: ProfileModelCatalogDto = state.list_codex_models.execute().await.into();
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_save_opencode_provider_profile(
|
||||
args: &Value,
|
||||
state: &BackendCore,
|
||||
@ -5862,6 +5874,7 @@ mod tests {
|
||||
project_id,
|
||||
task_id,
|
||||
owner_agent_id: owner,
|
||||
rendezvous: None,
|
||||
});
|
||||
let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
@ -7653,6 +7666,8 @@ mod tests {
|
||||
"list_profiles",
|
||||
"save_profile",
|
||||
"list_opencode_providers",
|
||||
"list_claude_models",
|
||||
"list_codex_models",
|
||||
"save_opencode_provider_profile",
|
||||
"delete_profile",
|
||||
"configure_profiles",
|
||||
|
||||
Reference in New Issue
Block a user