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:
2026-07-27 13:06:35 +02:00
63 changed files with 5674 additions and 993 deletions

View File

@ -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 },

View File

@ -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,

View File

@ -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();

View File

@ -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(

View File

@ -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,
};

View 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);
}
}

View File

@ -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
// ---------------------------------------------------------------------------

View File

@ -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,

View File

@ -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(),

View File

@ -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")

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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(&registry),
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(&registry),
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(&registry),
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(&registry),
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(&registry),
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());
}

View File

@ -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()));
}

View File

@ -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());
}
}

View File

@ -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 ».

View File

@ -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,

View File

@ -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 {

View File

@ -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,

View 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
);
}
}

View File

@ -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.

View File

@ -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,

View 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"));
}
}

View 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"
}
}

View File

@ -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;

View File

@ -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
);
}

View File

@ -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",