diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 61b6865..e4d95b9 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -70,3 +70,6 @@ - [ticket101-cross-talk-multi-project-rootcause](ticket101-cross-talk-multi-project-rootcause.md) — memory note ticket101-cross-talk-multi-project-rootcause - [ticket103-network-permission-ux-surface](ticket103-network-permission-ux-surface.md) — Stable UX convention for agent network permissions in IdeA. - [codex-network-access-config-fix](codex-network-access-config-fix.md) — memory note codex-network-access-config-fix +- [multi-profile-codex-claude-model-catalogue-scoping](multi-profile-codex-claude-model-catalogue-scoping.md) — memory note multi-profile-codex-claude-model-catalogue-scoping +- [model-catalogue-compat-cadrage](model-catalogue-compat-cadrage.md) — Frontières hexagonales, ports, DTO, fallback et matrice de compatibilité versionnée pour l'évolution du catalogue de modèles des profils structurés Codex/Claude. +- [tickets-70-100-102-ux-surface-scoping](tickets-70-100-102-ux-surface-scoping.md) — memory note tickets-70-100-102-ux-surface-scoping diff --git a/.ideai/memory/model-catalogue-compat-cadrage.md b/.ideai/memory/model-catalogue-compat-cadrage.md new file mode 100644 index 0000000..6aa85c9 --- /dev/null +++ b/.ideai/memory/model-catalogue-compat-cadrage.md @@ -0,0 +1,25 @@ +--- +name: model-catalogue-compat-cadrage +description: Frontières hexagonales, ports, DTO, fallback et matrice de compatibilité versionnée pour l'évolution du catalogue de modèles des profils structurés Codex/Claude. +metadata: + type: reference +--- +Évolution de `ListClaudeModels`/`ListCodexModels` (catalogue statique `application/src/agent/model_catalogue.rs`) vers un catalogue enrichi par compatibilité CLI. + +**Décisions tranchées :** +- JAMAIS scraper les TUI `/model`, JAMAIS exécuter les CLIs pour énumérer les modèles. Seule exécution CLI autorisée : ` --version` (pattern existant `infrastructure/runtime::detection_spec` + port `ProcessSpawner`). Saisie libre toujours ouverte. +- 3 sources non bloquantes à dégradation indépendante : API provider `/v1/models` (best-effort, uniquement si clé env/SecretStore présente, sinon skip), catalogue statique seed, matrice de compat. + +**Hexagonal :** +- Domaine (pur) : VO `CliVersion` (Ord), enum `ModelCompatibility {Compatible|Unknown|LikelyTooRecent}` (miroir des 3 états produit), VO `CompatibilityMatrix` (forme seule), fonction pure `evaluate_compatibility(matrix, adapter, model_id, Option)` — version None ⇒ Unknown, modèle absent ⇒ Unknown, min<=ver ⇒ Compatible, min>ver ⇒ LikelyTooRecent. +- Nouveaux ports : `CliVersionReader`, `ProviderModelCatalogue` (Ok(vec![]) si pas de clé), `CompatibilityMatrixSource` (infaillible). +- Application : use case unique `ResolveModelCatalogue{adapter}` async, jamais de hard-error sur échec source (warnings + fallback). `ListClaude/CodexModels` deviennent des façades. +- Infra : `ProcessCliVersionReader`, `HttpProviderModelCatalogue` (reqwest), `EmbeddedCompatibilityMatrix`. + +**Matrice de compat = DONNÉE, pas code** : JSON versionné maintenu dans IdeA, bundlé via `include_str!` (seed infaillible) + override optionnel `app_data_dir/IdeA/model-compat.json`. Ajouter un modèle = éditer le JSON, zéro code (Open/Closed). + +**DTO (rupture front)** : `ProfileModelCatalogDto` passe de `transparent Vec` à `{ models:[{...,compatibility,source}], cliVersion:string|null, warnings:string[] }`. Répercuter ports TS + 2 adapters + mock + ProfilesSettings. + +**Découpage** : B1 domaine pur, B2 use case ports mockés, B3 infra ; F1 contrat, F2 ProfilesSettings 3 badges. UX passe avant F2 (libellés des 3 états, warnings, cliVersion null). + +**Point ouvert produit** : récup clé provider — proposé best-effort sur clé env/SecretStore existante, pas de prompt dédié. \ No newline at end of file diff --git a/.ideai/memory/multi-profile-codex-claude-model-catalogue-scoping.md b/.ideai/memory/multi-profile-codex-claude-model-catalogue-scoping.md new file mode 100644 index 0000000..19f3b49 --- /dev/null +++ b/.ideai/memory/multi-profile-codex-claude-model-catalogue-scoping.md @@ -0,0 +1,50 @@ +--- +name: multi-profile-codex-claude-model-catalogue-scoping +description: memory note multi-profile-codex-claude-model-catalogue-scoping +metadata: + type: project +--- +# Cadrage : profils multiples Codex/Claude + catalogue de modèles + +Demande utilisateur : plusieurs profils Codex et Claude, chacun avec son modèle, assignables aux +agents ; lister les modèles plutôt que saisie manuelle quand possible. + +## État vérifié de l'existant (2026-07-26) + +Le backend est déjà générique multi-profils, contrairement à ce qu'on pourrait croire à la lecture +seule des mémoires F35/F36 (qui documentaient le cas OpenCode) : + +- `AgentProfile` (crates/domain/src/profile.rs) porte déjà `model: Option` (ticket #99, + explicitement prévu pour Codex/Claude), et `profiles.json` (FsProfileStore) est une **liste** + indexée par `id`, pas un slot singleton par provider. +- Commandes déjà câblées : `list_profiles`, `save_profile` (upsert générique par id), + `delete_profile`, `reference_profiles`, `detect_profiles`. +- Ce qui existe **seulement pour OpenCode** : `clone_opencode_profile_from_seed` (alloue un id + frais via IdGenerator) et `save_opencode_provider_profile`, plus un vrai catalogue de modèles + (`crates/application/src/agent/provider_catalogue.rs`, lit le cache models.dev d'OpenCode avec + repli statique). +- `catalogue.rs` : un seul profil de référence Claude et un seul Codex, aucun `.with_model(...)`. +- Frontend `ProfilesSettings.tsx` : simple list+delete, pas de create/duplicate/edit inline ; + toute édition rouvre `FirstRunWizard`. + +## Gaps identifiés (pas de migration de schéma nécessaire) + +1. Backend : généraliser le pattern `CloneOpenCodeProfileFromSeed` (fresh_profile_id via + IdGenerator) en un use case `CloneProfileFromSeed` non spécifique à OpenCode, pour dupliquer un + profil Claude/Codex avec un nom + `model` en override. Ne PAS laisser le frontend miner l'id + (romprait la discipline IdGenerator déjà en place). +2. Backend : catalogue de modèles Claude/Codex — aucune API fiable côté CLI, donc liste statique + curée (même esprit que `static_fallback_catalogue()` d'OpenCode), exposée par commande Tauri + infaillible (`list_claude_models`/`list_codex_models` ou générique par `structuredAdapter`). + Le frontend garde toujours un champ de saisie manuelle en repli (liste jamais garantie + exhaustive). +3. Frontend : refonte `ProfilesSettings.tsx` en onglets Codex/Claude/OpenCode avec + create/duplicate/edit/delete par onglet + `ModelSelect` searchable partagé ; simplifier + `FirstRunWizard` pour ne créer qu'un profil par défaut par provider détecté, avec renvoi vers + Settings pour en ajouter d'autres. + +## Découpage de livraison +DevBackend (use case + catalogues + commandes, petit lot, zéro migration) → DevFrontend (refonte +Settings + first-run simplifié) → QA (créer 2 profils Claude modèles différents + 2 Codex, assigner +à des agents distincts, vérifier le bon modèle atteint la CLI au lancement, non-régression +OpenCode) → Git (branche feature unique, lot petit et couplé). \ No newline at end of file diff --git a/.ideai/memory/tickets-70-100-102-ux-surface-scoping.md b/.ideai/memory/tickets-70-100-102-ux-surface-scoping.md new file mode 100644 index 0000000..ed0310f --- /dev/null +++ b/.ideai/memory/tickets-70-100-102-ux-surface-scoping.md @@ -0,0 +1,51 @@ +--- +name: tickets-70-100-102-ux-surface-scoping +description: memory note tickets-70-100-102-ux-surface-scoping +metadata: + type: project +--- +--- +title: Cadrage UX tickets #70 #100 #102 — modèles locaux et bugs terminal +type: design +description: Surface utilisateur attendue pour supprimer les modèles llama.cpp téléchargés, et règle UX pour les bugs de scroll/fit OpenCode qui doivent être corrigés sans nouvelle UI. +--- + +# Cadrage UX tickets #70 #100 #102 + +## #70 — Gestion des modèles locaux téléchargés + +Ajouter une affordance humaine de suppression des artefacts de modèles téléchargés par les serveurs locaux llama.cpp, dans la surface existante `Local model servers` / configuration OpenCode locale. + +Principes visibles : +- La suppression d'un serveur déclaré et la suppression du fichier modèle téléchargé sont deux actions distinctes. +- Une action destructive sur le fichier modèle doit être explicite, confirmée, et impossible pendant un usage actif/téléchargement du même artefact. +- Le libellé doit parler de `modèle téléchargé`, pas de cache interne ou chemin technique en premier niveau. +- Les modèles issus d'un `localPath` utilisateur ne doivent jamais être proposés à la suppression comme s'ils appartenaient à IdeA. + +États attendus par serveur : +- Aucun modèle téléchargé connu : aucun bouton de suppression de modèle, ou bouton désactivé avec tooltip `Aucun modèle téléchargé par IdeA`. +- Modèle téléchargé disponible : bouton secondaire/destructif `Supprimer le modèle téléchargé`. +- Téléchargement/préparation en cours : action désactivée, texte `Téléchargement en cours`. +- Serveur/agent utilisant ce modèle : action désactivée, texte `Modèle utilisé par un agent en cours`. +- Suppression en cours : ligne locale occupée, action désactivée, message `Suppression du modèle...`. +- Succès : toast/status non bloquant `Modèle téléchargé supprimé` ; la configuration serveur reste présente. +- Échec : alerte inline `Impossible de supprimer le modèle téléchargé : `. + +Confirmation : +- Titre : `Supprimer le modèle téléchargé ?` +- Corps : `Le serveur local restera configuré, mais IdeA devra retélécharger ce modèle au prochain lancement.` +- Si la taille est connue : ajouter `Espace libéré : .` +- Action principale destructive : `Supprimer le modèle` +- Action secondaire : `Annuler` + +## #100 — Scroll OpenCode + +Pas de décision UX spécifique. Le comportement attendu est celui d'une cellule terminal native : l'utilisateur peut remonter dans le scrollback OpenCode jusqu'à la limite de rétention disponible, avec molette, trackpad, scrollbar et clavier, sans blocage prématuré propre à OpenCode. + +Ne pas ajouter de bouton, message ou mode spécial OpenCode. QA doit valider le comportement visible dans une cellule OpenCode longue. + +## #102 — Fit TUI après switch/layout/ajout cellule + +Pas de nouvelle surface UX spécifique. Le terminal doit s'afficher correctement automatiquement après switch de projet, switch de layout, ajout/suppression/split/resize de cellules et rattachement d'une session existante. + +Ne pas afficher de message demandant à l'utilisateur de redimensionner. Éviter tout flash durable vide/noir ; un voile technique transitoire n'est acceptable que s'il reste très bref et non bloquant. \ No newline at end of file diff --git a/.ideai/tickets/100/issue.md b/.ideai/tickets/100/issue.md index 65569e5..0da3257 100644 --- a/.ideai/tickets/100/issue.md +++ b/.ideai/tickets/100/issue.md @@ -2,15 +2,15 @@ id: "4709958c-5082-44fd-a1fd-d6bad85f9361" number: 100 title: "[Bug] Problème sur le scroll des agents OpenCode" -status: "open" +status: "closed" priority: "high" sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2" links: [] agentRefs: [] createdBy: {"kind":"user"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdAt: 1784992615586 -updatedAt: 1784993984569 -version: 3 +updatedAt: 1785083912470 +version: 4 --- -Quand un agent est un agent opencode, je ne peux aps scroll très haut dans sa cellule. \ No newline at end of file +Quand un agent est un agent opencode, je ne peux aps scroll très haut dans sa cellule. diff --git a/.ideai/tickets/102/issue.md b/.ideai/tickets/102/issue.md index 9f6c347..a13e871 100644 --- a/.ideai/tickets/102/issue.md +++ b/.ideai/tickets/102/issue.md @@ -2,15 +2,15 @@ id: "e91fd358-da94-4382-aa70-6a3fe5a63840" number: 102 title: "[Bug] Devoir resize les cellule pour afficher la TUI d'un agent" -status: "open" +status: "closed" priority: "medium" sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2" links: [] agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdAt: 1784993319700 -updatedAt: 1784993980505 -version: 4 +updatedAt: 1785083912470 +version: 5 --- -J'ai toujours un soucis qui fait que quand je switch de projet IdeA ou de layout ou que j'ajoute des cellules ou autres, je suis obligé de resize un coup la cellule pour que son constenu s'affiche correctement \ No newline at end of file +J'ai toujours un soucis qui fait que quand je switch de projet IdeA ou de layout ou que j'ajoute des cellules ou autres, je suis obligé de resize un coup la cellule pour que son constenu s'affiche correctement diff --git a/.ideai/tickets/70/issue.md b/.ideai/tickets/70/issue.md index 945c969..0587f78 100644 --- a/.ideai/tickets/70/issue.md +++ b/.ideai/tickets/70/issue.md @@ -2,15 +2,15 @@ id: "e73ed25f-8e22-48a5-84ea-521212ea80b5" number: 70 title: "Pouvoir gérer les models AI locaux téléchargés via les serveur llamacpp" -status: "open" +status: "closed" priority: "low" sprint: null links: [] agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdAt: 1784194016529 -updatedAt: 1784194049447 -version: 4 +updatedAt: 1785083912470 +version: 5 --- -Avec les serveurs de models locaux llamacpp, on télécharge des modeles, j'aimerais aussi pouvoir les supprimer \ No newline at end of file +Avec les serveurs de models locaux llamacpp, on télécharge des modeles, j'aimerais aussi pouvoir les supprimer diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 053f7df..5c590ed 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 104 + "nextNumber": 107 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 462a4ec..be21241 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -731,13 +731,13 @@ "issueRef": "#70", "path": "70", "title": "Pouvoir gérer les models AI locaux téléchargés via les serveur llamacpp", - "status": "open", + "status": "closed", "priority": "low", "sprint": null, "assignedAgentIds": [ "a6ced819-b893-4213-b003-9e9dc79b9641" ], - "updatedAt": 1784194049447 + "updatedAt": 1785083912470 }, { "issueRef": "#71", @@ -1035,11 +1035,11 @@ "issueRef": "#100", "path": "100", "title": "[Bug] Problème sur le scroll des agents OpenCode", - "status": "open", + "status": "closed", "priority": "high", "sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2", "assignedAgentIds": [], - "updatedAt": 1784993984569 + "updatedAt": 1785083912470 }, { "issueRef": "#101", @@ -1057,13 +1057,13 @@ "issueRef": "#102", "path": "102", "title": "[Bug] Devoir resize les cellule pour afficher la TUI d'un agent", - "status": "open", + "status": "closed", "priority": "medium", "sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2", "assignedAgentIds": [ "a6ced819-b893-4213-b003-9e9dc79b9641" ], - "updatedAt": 1784993980505 + "updatedAt": 1785083912470 }, { "issueRef": "#103", @@ -1078,4 +1078,4 @@ "updatedAt": 1785013507979 } ] -} \ No newline at end of file +} diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index dd9d93d..469e797 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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 { + 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 { + 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 { + 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 }, diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 19c8ab1..a11990f 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -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, diff --git a/crates/app-tauri/tests/dto_model_servers.rs b/crates/app-tauri/tests/dto_model_servers.rs index 4b79093..87f93a7 100644 --- a/crates/app-tauri/tests/dto_model_servers.rs +++ b/crates/app-tauri/tests/dto_model_servers.rs @@ -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(); diff --git a/crates/app-tauri/tests/dto_profiles.rs b/crates/app-tauri/tests/dto_profiles.rs index 1615d28..7d583e2 100644 --- a/crates/app-tauri/tests/dto_profiles.rs +++ b/crates/app-tauri/tests/dto_profiles.rs @@ -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( diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 9035c68..5dbb49f 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -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, }; diff --git a/crates/application/src/agent/model_catalogue.rs b/crates/application/src/agent/model_catalogue.rs new file mode 100644 index 0000000..e5399b8 --- /dev/null +++ b/crates/application/src/agent/model_catalogue.rs @@ -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 +//! ` --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, + /// 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 { + 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 { + 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, + /// Best-effort local CLI version. + pub cli_version: Option, + /// Non-fatal fallback/degradation warnings. + pub warnings: Vec, +} + +/// Output of [`ListClaudeModels::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListClaudeModelsOutput { + /// The catalogue entries. + pub models: Vec, + /// Best-effort local CLI version. + pub cli_version: Option, + /// Non-fatal fallback/degradation warnings. + pub warnings: Vec, +} + +impl From 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, + /// Best-effort local CLI version. + pub cli_version: Option, + /// Non-fatal fallback/degradation warnings. + pub warnings: Vec, +} + +impl From 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, + provider_catalogue: Arc, + matrix_source: Arc, +} + +impl ResolveModelCatalogue { + /// Builds the use case from hexagonal ports. + #[must_use] + pub fn new( + cli_versions: Arc, + provider_catalogue: Arc, + matrix_source: Arc, + ) -> 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::>(); + 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, + provider_catalogue: Arc, + matrix_source: Arc, + ) -> 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, + provider_catalogue: Arc, + matrix_source: Arc, + ) -> 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, String>>); + + #[async_trait] + impl CliVersionReader for FakeCliVersionReader { + async fn read_cli_version( + &self, + _adapter: StructuredAdapter, + ) -> Result, String> { + self.0 + .clone() + .unwrap_or_else(|| Ok(Some(CliVersion::parse("1.0.0").unwrap()))) + } + } + + struct FakeProvider(Vec, Option); + + #[async_trait] + impl ProviderModelCatalogue for FakeProvider { + async fn list_provider_models( + &self, + _adapter: StructuredAdapter, + ) -> Result, String> { + if let Some(warning) = &self.1 { + Err(warning.clone()) + } else { + Ok(self.0.clone()) + } + } + } + + struct FakeMatrixSource(CompatibilityMatrix, Vec); + + impl CompatibilityMatrixSource for FakeMatrixSource { + fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec) { + (self.0.clone(), self.1.clone()) + } + } + + fn resolver( + version: Option, String>>, + provider: Vec, + provider_warning: Option, + ) -> 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); + } +} diff --git a/crates/application/src/agent/usecases.rs b/crates/application/src/agent/usecases.rs index 5cfd809..8cd82fa 100644 --- a/crates/application/src/agent/usecases.rs +++ b/crates/application/src/agent/usecases.rs @@ -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, + /// Optional model override. When absent, the seed model is copied as-is. + pub model: Option, +} + +/// 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, + ids: Arc, +} + +impl CloneProfileFromSeed { + /// Builds the use case from the profile store and id generator ports. + #[must_use] + pub fn new(store: Arc, ids: Arc) -> Self { + Self { store, ids } + } + + /// Clones the 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 { + 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 // --------------------------------------------------------------------------- diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 851181c..426b1b6 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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, diff --git a/crates/application/src/model_server.rs b/crates/application/src/model_server.rs index 4f9163e..bf40166 100644 --- a/crates/application/src/model_server.rs +++ b/crates/application/src/model_server.rs @@ -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, + }, +} + +impl From 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, + /// Persisted local model-server configs enriched with artifact state. + pub servers: Vec, } /// Lists local model-server configurations. pub struct ListModelServers { registry: Arc, + downloader: Option>, + downloads: Option>, } impl ListModelServers { /// Builds the use case. #[must_use] pub fn new(registry: Arc) -> 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, + ) -> Self { + self.downloader = Some(downloader); + self + } + + /// Enables in-flight download state enrichment. + #[must_use] + pub fn with_download_tracker( + mut self, + downloads: Arc, + ) -> 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 { - 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 { + 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, + probe: Arc, + downloader: Arc, + downloads: Arc, + profiles: Arc, + projects: Arc, + contexts: Arc, + live: Arc, +} + +impl DeleteModelArtifact { + /// Builds the use case. + #[allow(clippy::too_many_arguments)] + #[must_use] + pub fn new( + registry: Arc, + probe: Arc, + downloader: Arc, + downloads: Arc, + profiles: Arc, + projects: Arc, + contexts: Arc, + live: Arc, + ) -> 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::, _>>()?, + ); + 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(), diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 7a85fb3..e6f0239 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -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 { + 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 { 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") diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index 0d4e657..c1ee5cd 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -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 { + 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 { + 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 { + 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 { - let mut all: Vec = 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::>() - }) - .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 { + LiveSessions::live_agent_snapshots(self) + } } diff --git a/crates/application/src/workstate/mod.rs b/crates/application/src/workstate/mod.rs index b75a150..184650a 100644 --- a/crates/application/src/workstate/mod.rs +++ b/crates/application/src/workstate/mod.rs @@ -231,6 +231,12 @@ pub struct AgentBackgroundTaskState { pub stdout_tail: Option, /// Bounded stderr tail. pub stderr_tail: Option, + /// Agent that requested a headless rendezvous, when this task is one. + pub requester_agent_id: Option, + /// Target agent for a headless rendezvous, when this task is one. + pub target_agent_id: Option, + /// Conversation opened by a headless rendezvous, when this task is one. + pub conversation_id: Option, /// Creation timestamp, epoch milliseconds. pub created_at_ms: u64, /// Last update timestamp, epoch milliseconds. @@ -592,6 +598,8 @@ impl GetProjectWorkState { impl From 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 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, + Option, + Option, +) { + 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 { diff --git a/crates/application/tests/model_server.rs b/crates/application/tests/model_server.rs index 9cd50f4..38fb359 100644 --- a/crates/application/tests/model_server.rs +++ b/crates/application/tests/model_server.rs @@ -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, + deleted: Mutex>, } 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 { + 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>, +} + +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, +} + +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 { + 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, StoreError> { + Ok(Vec::new()) + } + + async fn load_project(&self, id: ProjectId) -> Result { + 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 { + 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 { + 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 { + 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) } +fn delete_artifact_usecase( + registry: Arc, + downloader: Arc, + tracker: Arc, + profiles: Arc, +) -> 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, + downloader: Arc, + tracker: Arc, + profiles: Arc, + probe: Arc, + projects: Arc, + contexts: Arc, + live: Arc, +) -> DeleteModelArtifact { + DeleteModelArtifact::new( + registry as Arc, + probe as Arc, + downloader as Arc, + tracker as Arc, + profiles as Arc, + projects as Arc, + contexts as Arc, + live as Arc, + ) +} + fn progress(downloaded: Option, total: Option) -> ModelArtifactProgress { ModelArtifactProgress { downloaded_bytes: downloaded, @@ -1409,3 +1592,218 @@ async fn delete_model_server_removes_unused_config() { assert!(registry.get(&sid(9)).await.unwrap().is_none()); } + +#[tokio::test] +async fn delete_model_artifact_refuses_local_path_source() { + let registry = Arc::new(FakeRegistry::default()); + registry + .save(config(sid(25), 8105, "/models/qwen.gguf", false)) + .await + .unwrap(); + let downloader = Arc::new(FakeModelArtifactDownloader::new( + FakeDownloadOutcome::Resolve { + progress: Vec::new(), + path: "/cache/model.gguf", + cache_hit: true, + }, + )); + let usecase = delete_artifact_usecase( + Arc::clone(®istry), + Arc::clone(&downloader), + Arc::new(FakeDownloadTracker::default()), + Arc::new(FakeProfiles::default()), + ); + + let err = usecase + .execute(DeleteModelArtifactInput { server_id: sid(25) }) + .await + .unwrap_err(); + + match err { + application::AppError::ModelServer { code, .. } => assert_eq!(code, "invalid"), + other => panic!("unexpected error: {other}"), + } + assert!(downloader.deleted.lock().unwrap().is_empty()); + assert!(registry.get(&sid(25)).await.unwrap().is_some()); +} + +#[tokio::test] +async fn delete_model_artifact_refuses_download_in_progress() { + let registry = Arc::new(FakeRegistry::default()); + registry + .save(hf_config(sid(26), 8106, "Qwen/Qwen3-Coder:Q4_K_M")) + .await + .unwrap(); + let tracker = Arc::new(FakeDownloadTracker::default()); + tracker.in_progress.lock().unwrap().push(sid(26)); + let downloader = Arc::new(FakeModelArtifactDownloader::new( + FakeDownloadOutcome::Resolve { + progress: Vec::new(), + path: "/cache/q4.gguf", + cache_hit: true, + }, + )); + let usecase = delete_artifact_usecase( + Arc::clone(®istry), + Arc::clone(&downloader), + tracker, + Arc::new(FakeProfiles::default()), + ); + + let err = usecase + .execute(DeleteModelArtifactInput { server_id: sid(26) }) + .await + .unwrap_err(); + + match err { + application::AppError::ModelServer { code, .. } => { + assert_eq!(code, "model_server_in_use"); + } + other => panic!("unexpected error: {other}"), + } + assert!(downloader.deleted.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn delete_model_artifact_refuses_live_agent_using_server_profile() { + let server_id = sid(27); + let project_id = pid(27); + let agent_id = aid(27); + let profile_id = ProfileId::from_uuid(uuid::Uuid::from_u128(270)); + let registry = Arc::new(FakeRegistry::default()); + registry + .save(hf_config(server_id, 8107, "Qwen/Qwen3-Coder:Q4_K_M")) + .await + .unwrap(); + let profiles = Arc::new(FakeProfiles(Mutex::new(vec![opencode_profile( + profile_id.as_uuid().as_u128(), + server_id, + )]))); + let contexts = Arc::new(FakeContexts { + manifest: AgentManifest::new( + 1, + vec![ManifestEntry::new( + agent_id, + "Local Agent", + "agents/local.md", + profile_id, + None, + false, + None, + ) + .unwrap()], + ) + .unwrap(), + }); + let live = Arc::new(FakeLive { + snapshots: vec![LiveSessionSnapshot { + project_id, + agent_id, + node_id: nid(27), + session_id: sess(27), + kind: LiveSessionKind::Pty, + }], + }); + let downloader = Arc::new(FakeModelArtifactDownloader::new( + FakeDownloadOutcome::Resolve { + progress: Vec::new(), + path: "/cache/q4.gguf", + cache_hit: true, + }, + )); + let usecase = delete_artifact_usecase_with_live( + Arc::clone(®istry), + Arc::clone(&downloader), + Arc::new(FakeDownloadTracker::default()), + profiles, + Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])), + Arc::new(FakeProjects { project_id }), + contexts, + live, + ); + + let err = usecase + .execute(DeleteModelArtifactInput { server_id }) + .await + .unwrap_err(); + + match err { + application::AppError::ModelServer { code, .. } => { + assert_eq!(code, "model_server_in_use"); + } + other => panic!("unexpected error: {other}"), + } + assert!(downloader.deleted.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn delete_model_artifact_refuses_reachable_server_endpoint() { + let registry = Arc::new(FakeRegistry::default()); + registry + .save(hf_config(sid(28), 8108, "Qwen/Qwen3-Coder:Q4_K_M")) + .await + .unwrap(); + let downloader = Arc::new(FakeModelArtifactDownloader::new( + FakeDownloadOutcome::Resolve { + progress: Vec::new(), + path: "/cache/q4.gguf", + cache_hit: true, + }, + )); + let usecase = delete_artifact_usecase_with_live( + Arc::clone(®istry), + Arc::clone(&downloader), + Arc::new(FakeDownloadTracker::default()), + Arc::new(FakeProfiles::default()), + Arc::new(FakeProbe::new(vec![ModelServerStatus::ReadyReused])), + Arc::new(FakeProjects::default()), + Arc::new(FakeContexts::default()), + Arc::new(FakeLive::default()), + ); + + let err = usecase + .execute(DeleteModelArtifactInput { server_id: sid(28) }) + .await + .unwrap_err(); + + match err { + application::AppError::ModelServer { code, .. } => { + assert_eq!(code, "model_server_in_use"); + } + other => panic!("unexpected error: {other}"), + } + assert!(downloader.deleted.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn delete_model_artifact_deletes_hf_cache_without_deleting_config() { + let registry = Arc::new(FakeRegistry::default()); + registry + .save(hf_config(sid(27), 8107, "Qwen/Qwen3-Coder:Q4_K_M")) + .await + .unwrap(); + let downloader = Arc::new(FakeModelArtifactDownloader::new( + FakeDownloadOutcome::Resolve { + progress: Vec::new(), + path: "/cache/q4.gguf", + cache_hit: true, + }, + )); + let usecase = delete_artifact_usecase( + Arc::clone(®istry), + Arc::clone(&downloader), + Arc::new(FakeDownloadTracker::default()), + Arc::new(FakeProfiles::default()), + ); + + usecase + .execute(DeleteModelArtifactInput { server_id: sid(27) }) + .await + .unwrap(); + + assert_eq!( + downloader.deleted.lock().unwrap().as_slice(), + ["Qwen/Qwen3-Coder:Q4_K_M"] + ); + assert!(registry.get(&sid(27)).await.unwrap().is_some()); +} diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 9db2a6c..cf64afc 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -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())); +} diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 258065a..ec07b76 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -1047,6 +1047,78 @@ impl From for ProfileDto { } } +impl From 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, + /// 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 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, + /// Best-effort local CLI version. + pub cli_version: Option, + /// Non-fatal fallback/degradation warnings. + pub warnings: Vec, +} + +impl From 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 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 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, + /// Optional model override. When omitted, the seed model is copied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +impl From 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 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, + /// 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, + }, +} + +impl Default for ModelArtifactDto { + fn default() -> Self { + Self::NotManaged + } +} + +impl From 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 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 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, + /// Agent that requested a headless rendezvous, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub requester_agent_id: Option, + /// Target agent for a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + pub target_agent_id: Option, + /// Conversation opened by a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, /// Creation timestamp, epoch milliseconds. pub created_at_ms: u64, /// Last update timestamp, epoch milliseconds. @@ -2477,6 +2636,9 @@ impl From 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, + /// Agent that requested a headless rendezvous, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub requester_agent_id: Option, + /// Target agent for a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + pub target_agent_id: Option, + /// Conversation opened by a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, /// 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 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 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, Option, Option) { + 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()); + } } diff --git a/crates/backend/src/events.rs b/crates/backend/src/events.rs index 060c0e4..68864bd 100644 --- a/crates/backend/src/events.rs +++ b/crates/backend/src/events.rs @@ -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, + /// Target agent for a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + target_agent_id: Option, + /// Conversation opened by a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + conversation_id: Option, }, /// 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) -> Option { + rendezvous + .as_ref() + .and_then(|ctx| ctx.requester_agent_id.map(|id| id.to_string())) +} + +fn rendezvous_target_agent_id(rendezvous: &Option) -> Option { + rendezvous + .as_ref() + .map(|ctx| ctx.target_agent_id.to_string()) +} + +fn rendezvous_conversation_id(rendezvous: &Option) -> Option { + 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 ». diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 5526b71..e63426f 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -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, /// Static catalogue of OpenCode cloud providers (ticket #92, lot B3). pub list_opencode_providers: Arc, + /// Static curated Claude model catalogue. + pub list_claude_models: Arc, + /// Static curated Codex model catalogue. + pub list_codex_models: Arc, + /// Create a new profile instance from a persisted/reference seed. + pub clone_profile_from_seed: Arc, /// Create a new OpenCode profile instance from the canonical seed. pub clone_opencode_profile_from_seed: Arc, /// Delete a profile. @@ -958,6 +965,8 @@ pub struct BackendCore { pub save_model_server: Arc, /// Deletes local model server configurations when unused. pub delete_model_server: Arc, + /// Deletes managed local model artifacts without deleting server configs. + pub delete_model_artifact: Arc, /// 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, @@ -1468,6 +1477,25 @@ impl BackendCore { Arc::clone(&ids) as Arc, )); 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, + Arc::clone(&provider_model_catalogue) as Arc, + Arc::clone(&compatibility_matrix) as Arc, + )); + let list_codex_models = Arc::new(ListCodexModels::new( + cli_version_reader as Arc, + provider_model_catalogue as Arc, + compatibility_matrix as Arc, + )); + let clone_profile_from_seed = Arc::new(CloneProfileFromSeed::new( + Arc::clone(&profile_store_port), + Arc::clone(&ids) as Arc, + )); let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new( Arc::clone(&profile_store_port), Arc::clone(&ids) as Arc, @@ -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; let ensure_local_model_server = Arc::new( EnsureLocalModelServer::new( Arc::clone(&model_server_registry) as Arc, - Arc::new(HttpOpenAiCompatibleProbe::default()) - as Arc, + Arc::clone(&model_server_probe_port), Arc::new(LocalManagedProcess::new()) as Arc, Arc::new(LlamaCppRuntime::new()) as Arc, Arc::clone(&fs_port), Arc::clone(&events_port), ) - .with_model_artifact_downloader( - model_artifact_downloader as Arc, - ), + .with_model_artifact_downloader(Arc::clone(&model_artifact_downloader) + as Arc), ); let model_server_registry_port = Arc::clone(&model_server_registry) as Arc; - let list_model_servers = Arc::new(ListModelServers::new(Arc::clone( - &model_server_registry_port, - ))); + let model_artifact_downloader_port = + Arc::clone(&model_artifact_downloader) as Arc; + let model_artifact_download_tracker = Arc::clone(&ensure_local_model_server) + as Arc; + 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, + )); // 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, diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 9e44c52..ce1a283 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -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, + /// 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, }, /// 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, }, /// 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, }, /// A first-class background task has a terminal result not yet delivered. BackgroundTaskCompletionDeliveryPending { diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index d3c5a4e..f6cd8f0 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -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, diff --git a/crates/domain/src/model_catalogue.rs b/crates/domain/src/model_catalogue.rs new file mode 100644 index 0000000..88bee67 --- /dev/null +++ b/crates/domain/src/model_catalogue.rs @@ -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, +} + +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) -> Result { + 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::(); + let parts = version + .split('.') + .filter(|part| !part.is_empty()) + .map(str::parse::) + .collect::, _>>() + .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 { + 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, + /// Codex CLI entries keyed by model id. + #[serde(default)] + pub codex: HashMap, +} + +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 + ); + } +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index d4e5214..587961f 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -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; } +/// 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, 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, 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); +} + /// 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, + }, +} + /// 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; + /// Resolves a Hugging Face model to a local artifact path. /// /// # Errors @@ -1317,6 +1374,14 @@ pub trait ModelArtifactDownloader: Send + Sync { progress: Arc, cancel: ModelArtifactCancel, ) -> Result; + + /// 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. diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 18963df..eb89443 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -26,6 +26,7 @@ pub mod input; pub mod inspector; pub mod issues; pub mod mailbox; +pub mod model_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, diff --git a/crates/infrastructure/src/model_catalogue.rs b/crates/infrastructure/src/model_catalogue.rs new file mode 100644 index 0000000..8759f62 --- /dev/null +++ b/crates/infrastructure/src/model_catalogue.rs @@ -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, +} + +impl ProcessCliVersionReader { + /// Builds the adapter from the process-spawner port. + #[must_use] + pub fn new(spawner: Arc) -> Self { + Self { spawner } + } + + fn spec(adapter: StructuredAdapter) -> Option { + 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, 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, String> { + #[derive(Deserialize)] + struct Response { + data: Vec, + } + #[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::() + .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, String> { + #[derive(Deserialize)] + struct Response { + data: Vec, + } + #[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::() + .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, 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) -> Vec { + values + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + .collect::>() + .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, +} + +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) -> 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) { + 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::(&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>, + } + + #[async_trait] + impl ProcessSpawner for RecordingSpawner { + async fn run(&self, spec: SpawnSpec) -> Result { + 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")); + } +} diff --git a/crates/infrastructure/src/model_compatibility_matrix.json b/crates/infrastructure/src/model_compatibility_matrix.json new file mode 100644 index 0000000..a0af0a8 --- /dev/null +++ b/crates/infrastructure/src/model_compatibility_matrix.json @@ -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" + } +} diff --git a/crates/infrastructure/src/model_server/mod.rs b/crates/infrastructure/src/model_server/mod.rs index 209dea5..bffbeb6 100644 --- a/crates/infrastructure/src/model_server/mod.rs +++ b/crates/infrastructure/src/model_server/mod.rs @@ -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) -> bool { pub struct HfModelArtifactDownloader { cache_dir: PathBuf, client: reqwest::Client, + repo_locks: Arc>>>>, } 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> { + 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 { + 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 { + 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, cancel: ModelArtifactCancel, ) -> Result { + 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 .map_err(|e| ModelServerError::Invalid(e.to_string())) } +fn file_size(path: &Path) -> Result { + std::fs::metadata(path) + .map(|metadata| metadata.len()) + .map_err(|e| ModelServerError::Store(e.to_string())) +} + +fn paths_size(paths: &[PathBuf]) -> Result { + 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; diff --git a/crates/infrastructure/tests/model_server.rs b/crates/infrastructure/tests/model_server.rs index e5de42e..949509b 100644 --- a/crates/infrastructure/tests/model_server.rs +++ b/crates/infrastructure/tests/model_server.rs @@ -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 + ); +} diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index dc79106..6853d1f 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -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 Result { + 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 { + 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", diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index c2c7dfc..9382dbb 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -42,6 +42,7 @@ import type { ProjectWorkState, ProjectSystemPermissions, ProfileAvailability, + ProfileModelCatalog, ResolvedAgentSystemPermissions, SystemPermissionSet, Skill, @@ -50,6 +51,7 @@ import type { TurnPage, } from "@/domain"; import type { + CloneProfileFromSeedInput, CloneOpenCodeProfileFromSeedInput, ConversationGateway, ConversationPageRequest, @@ -72,6 +74,7 @@ import type { } from "@/ports"; import { normalizeProjectWorkState } from "../workStateNormalization"; import { normalizeTurnPage } from "../conversationNormalization"; +import { normalizeProfileModelCatalog } from "../profileCatalog"; import type { HttpInvoker } from "./httpInvoker"; export class HttpProjectGateway implements ProjectGateway { @@ -176,6 +179,17 @@ export class HttpProfileGateway implements ProfileGateway { async deleteProfile(profileId: string): Promise { await this.http.invoke("delete_profile", { profileId }); } + cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise { + return this.http.invoke("clone_profile_from_seed", { + request: { seedProfileId: input.seedProfileId, name: input.name, model: input.model }, + }); + } + async listClaudeModels(): Promise { + return normalizeProfileModelCatalog(await this.http.invoke("list_claude_models")); + } + async listCodexModels(): Promise { + return normalizeProfileModelCatalog(await this.http.invoke("list_codex_models")); + } configureProfiles(profiles: AgentProfile[]): Promise { return this.http.invoke("configure_profiles", { request: { profiles } }); } @@ -215,6 +229,9 @@ export class HttpModelServerGateway implements ModelServerGateway { async deleteModelServer(serverId: string): Promise { await this.http.invoke("delete_model_server", { serverId }); } + async deleteModelArtifact(serverId: string): Promise { + await this.http.invoke("delete_model_artifact", { serverId }); + } previewModelServerCommand(config: LocalModelServerConfig): Promise { return this.http.invoke("preview_model_server_command", { config }); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 153a036..888b6d2 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -35,6 +35,8 @@ import type { McpToolCatalogue, McpToolPolicy, OpenCodeProviderCatalogEntry, + ProfileModelCatalog, + ProfileModelCatalogEntry, EffectivePermissions, PairedDevice, PairingCode, @@ -80,6 +82,7 @@ import type { ConversationGateway, ConversationPageRequest, ConversationDetails, + CloneProfileFromSeedInput, CloneOpenCodeProfileFromSeedInput, CreateAgentInput, CreateMemoryInput, @@ -1293,6 +1296,66 @@ const MOCK_OPENCODE_PROVIDERS: OpenCodeProviderCatalogEntry[] = [ }, ]; +const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [ + { + adapter: "claude", + modelId: "claude-sonnet-5", + displayName: "Claude Sonnet 5", + aliases: ["sonnet"], + recommended: true, + compatibility: "compatible", + source: "catalogue", + }, + { + adapter: "claude", + modelId: "claude-opus-4-8", + displayName: "Claude Opus 4.8", + aliases: ["opus"], + recommended: false, + compatibility: "unknown", + source: "catalogue", + }, + { + adapter: "claude", + modelId: "claude-haiku-4-5-20251001", + displayName: "Claude Haiku 4.5", + aliases: ["haiku"], + recommended: false, + compatibility: "likelyTooRecent", + source: "provider", + }, +]; + +const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [ + { + adapter: "codex", + modelId: "gpt-5-codex", + displayName: "GPT-5 Codex", + aliases: ["codex"], + recommended: true, + compatibility: "compatible", + source: "catalogue", + }, + { + adapter: "codex", + modelId: "gpt-5", + displayName: "GPT-5", + aliases: ["general"], + recommended: false, + compatibility: "unknown", + source: "catalogue", + }, + { + adapter: "codex", + modelId: "gpt-5-mini", + displayName: "GPT-5 mini", + aliases: ["mini", "fast"], + recommended: false, + compatibility: "likelyTooRecent", + source: "provider", + }, +]; + /** * In-memory profiles gateway. Tracks configured profiles and a first-run flag so * the wizard can be driven and tested fully offline. By default it reports the @@ -1348,6 +1411,44 @@ export class MockProfileGateway implements ProfileGateway { return structuredClone(profiles); } + async cloneProfileFromSeed( + input: CloneProfileFromSeedInput, + ): Promise { + const seed = [...this.profiles, ...MOCK_REFERENCE_PROFILES].find( + (p) => p.id === input.seedProfileId, + ); + if (!seed) throw new Error(`unknown profile seed: ${input.seedProfileId}`); + this.cloneCounter += 1; + const cloned: AgentProfile = { + ...structuredClone(seed), + id: `mock-profile-clone-${this.cloneCounter}`, + name: input.name ?? `${seed.name} copy`, + model: + input.model !== undefined && input.model.trim() !== "" + ? input.model + : seed.model, + }; + this.profiles.push(cloned); + this.configured = true; + return structuredClone(cloned); + } + + async listClaudeModels(): Promise { + return { + models: structuredClone(MOCK_CLAUDE_MODELS), + cliVersion: "2.1.220", + warnings: [], + }; + } + + async listCodexModels(): Promise { + return { + models: structuredClone(MOCK_CODEX_MODELS), + cliVersion: "0.145.0", + warnings: ["Catalogue provider partiellement estime depuis les donnees locales."], + }; + } + async cloneOpenCodeProfileFromSeed( input: CloneOpenCodeProfileFromSeedInput = {}, ): Promise { @@ -1424,6 +1525,36 @@ export class MockModelServerGateway implements ModelServerGateway { this.servers = this.servers.filter((s) => s.id !== serverId); } + async deleteModelArtifact(serverId: string): Promise { + const i = this.servers.findIndex((s) => s.id === serverId); + if (i < 0) { + const err: GatewayError = { + code: "not_configured", + message: "model server is not configured", + }; + throw err; + } + const server = this.servers[i]; + if (server.modelSource?.type !== "huggingFace") { + const err: GatewayError = { + code: "invalid", + message: "only managed Hugging Face model artifacts can be deleted", + }; + throw err; + } + if (server.artifact?.state === "downloading" || this.inUse.has(serverId)) { + const err: GatewayError = { + code: "model_server_in_use", + message: "model artifact cannot be deleted while in use", + }; + throw err; + } + this.servers[i] = { + ...server, + artifact: { state: "missing" }, + }; + } + async previewModelServerCommand( config: LocalModelServerConfig, ): Promise { diff --git a/frontend/src/adapters/modelServer.ts b/frontend/src/adapters/modelServer.ts index 72a1d75..8905b19 100644 --- a/frontend/src/adapters/modelServer.ts +++ b/frontend/src/adapters/modelServer.ts @@ -32,6 +32,10 @@ export class TauriModelServerGateway implements ModelServerGateway { await invoke("delete_model_server", { serverId }); } + async deleteModelArtifact(serverId: string): Promise { + await invoke("delete_model_artifact", { serverId }); + } + previewModelServerCommand( config: LocalModelServerConfig, ): Promise { diff --git a/frontend/src/adapters/profile.ts b/frontend/src/adapters/profile.ts index ea355ed..f5b4be1 100644 --- a/frontend/src/adapters/profile.ts +++ b/frontend/src/adapters/profile.ts @@ -12,13 +12,16 @@ import type { AgentProfile, FirstRunState, OpenCodeProviderCatalogEntry, + ProfileModelCatalog, ProfileAvailability, } from "@/domain"; import type { + CloneProfileFromSeedInput, CloneOpenCodeProfileFromSeedInput, ProfileGateway, SaveOpenCodeProviderProfileInput, } from "@/ports"; +import { normalizeProfileModelCatalog } from "./profileCatalog"; export class TauriProfileGateway implements ProfileGateway { firstRunState(): Promise { @@ -47,6 +50,24 @@ export class TauriProfileGateway implements ProfileGateway { await invoke("delete_profile", { profileId }); } + cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise { + return invoke("clone_profile_from_seed", { + request: { + seedProfileId: input.seedProfileId, + name: input.name, + model: input.model, + }, + }); + } + + async listClaudeModels(): Promise { + return normalizeProfileModelCatalog(await invoke("list_claude_models")); + } + + async listCodexModels(): Promise { + return normalizeProfileModelCatalog(await invoke("list_codex_models")); + } + configureProfiles(profiles: AgentProfile[]): Promise { return invoke("configure_profiles", { request: { profiles }, diff --git a/frontend/src/adapters/profileCatalog.test.ts b/frontend/src/adapters/profileCatalog.test.ts new file mode 100644 index 0000000..3c9066e --- /dev/null +++ b/frontend/src/adapters/profileCatalog.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeProfileModelCatalog } from "./profileCatalog"; + +describe("normalizeProfileModelCatalog", () => { + it("wraps the legacy bare array response with unknown compatibility", () => { + expect( + normalizeProfileModelCatalog([ + { + adapter: "codex", + modelId: "gpt-5-codex", + displayName: "GPT-5 Codex", + aliases: ["codex"], + recommended: true, + }, + ]), + ).toEqual({ + models: [ + { + adapter: "codex", + modelId: "gpt-5-codex", + displayName: "GPT-5 Codex", + aliases: ["codex"], + recommended: true, + compatibility: "unknown", + source: "catalogue", + }, + ], + cliVersion: null, + warnings: [], + }); + }); + + it("defaults missing enriched fields without dropping warnings", () => { + expect( + normalizeProfileModelCatalog({ + models: [{ adapter: "claude", modelId: "claude-sonnet-5" }], + warnings: ["version inconnue"], + }), + ).toEqual({ + models: [ + { + adapter: "claude", + modelId: "claude-sonnet-5", + displayName: "claude-sonnet-5", + aliases: [], + recommended: false, + compatibility: "unknown", + source: "catalogue", + }, + ], + cliVersion: null, + warnings: ["version inconnue"], + }); + }); +}); diff --git a/frontend/src/adapters/profileCatalog.ts b/frontend/src/adapters/profileCatalog.ts new file mode 100644 index 0000000..42adf9e --- /dev/null +++ b/frontend/src/adapters/profileCatalog.ts @@ -0,0 +1,63 @@ +import type { + ModelCatalogSource, + ModelCompatibility, + ProfileModelCatalog, + ProfileModelCatalogEntry, +} from "@/domain"; + +type PartialCatalogEntry = Partial & { + adapter?: "claude" | "codex"; + modelId?: string; + displayName?: string; +}; + +function compatibilityOf(value: unknown): ModelCompatibility { + return value === "compatible" || + value === "unknown" || + value === "likelyTooRecent" + ? value + : "unknown"; +} + +function sourceOf(value: unknown): ModelCatalogSource { + return value === "provider" ? "provider" : "catalogue"; +} + +function normalizeEntry(raw: PartialCatalogEntry): ProfileModelCatalogEntry { + const modelId = raw.modelId ?? ""; + return { + adapter: raw.adapter ?? "codex", + modelId, + displayName: raw.displayName ?? modelId, + aliases: Array.isArray(raw.aliases) ? raw.aliases : [], + recommended: Boolean(raw.recommended), + compatibility: compatibilityOf(raw.compatibility), + source: sourceOf(raw.source), + }; +} + +export function normalizeProfileModelCatalog(raw: unknown): ProfileModelCatalog { + if (Array.isArray(raw)) { + return { + models: raw.map((entry) => normalizeEntry(entry as PartialCatalogEntry)), + cliVersion: null, + warnings: [], + }; + } + + const catalog = + raw && typeof raw === "object" + ? (raw as Partial) + : {}; + + return { + models: Array.isArray(catalog.models) + ? catalog.models.map((entry) => normalizeEntry(entry as PartialCatalogEntry)) + : [], + cliVersion: + typeof catalog.cliVersion === "string" ? catalog.cliVersion : null, + warnings: Array.isArray(catalog.warnings) + ? catalog.warnings.map(String) + : [], + }; +} diff --git a/frontend/src/adapters/workStateNormalization.ts b/frontend/src/adapters/workStateNormalization.ts index 295c73d..3d76e09 100644 --- a/frontend/src/adapters/workStateNormalization.ts +++ b/frontend/src/adapters/workStateNormalization.ts @@ -100,6 +100,15 @@ function normalizeBackgroundTask( fallbackAgentId: string, ): BackgroundCompletion { const task = isRecord(value) ? value : {}; + const requesterAgentId = optionalString( + task.requesterAgentId ?? task.requester_agent_id, + ); + const targetAgentId = optionalString( + task.targetAgentId ?? task.target_agent_id, + ); + const conversationId = optionalString( + task.conversationId ?? task.conversation_id, + ); return { taskId: stringValue(task.taskId, `legacy-task-${index}`), ownerAgentId: stringValue(task.ownerAgentId, fallbackAgentId), @@ -110,6 +119,9 @@ function normalizeBackgroundTask( summary: nullableString(task.summary), stdoutTail: nullableString(task.stdoutTail), stderrTail: nullableString(task.stderrTail), + ...(requesterAgentId ? { requesterAgentId } : {}), + ...(targetAgentId ? { targetAgentId } : {}), + ...(conversationId ? { conversationId } : {}), updatedAtMs: numberValue(task.updatedAtMs), }; } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 936164a..70df616 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -99,6 +99,17 @@ export type ModelSource = | { type: "localPath"; path: string } | { type: "huggingFace"; repo: string }; +/** + * Derived state of an IdeA-managed local model artifact cache (#70, mirror of + * `ModelArtifactDto`, tagged on `state`). Only Hugging Face sources are managed; + * local `.gguf` paths stay `notManaged`. + */ +export type ModelArtifact = + | { state: "notManaged" } + | { state: "missing" } + | { state: "downloading" } + | { state: "downloaded"; path: string; sizeBytes?: number }; + /** * A declared local model server (F35, mirror of the backend flat * `LocalModelServerConfigDto`, camelCase wire). Global to IdeA (not project @@ -138,6 +149,8 @@ export interface LocalModelServerConfig { args: string[]; autoStart: boolean; stopPolicy: StopPolicy; + /** Derived managed artifact cache state. Present on list results. */ + artifact?: ModelArtifact; } /** @@ -287,6 +300,9 @@ export type DomainEvent = taskId: string; agentId: string; state: string; + requesterAgentId?: string; + targetAgentId?: string; + conversationId?: string; } | { type: "agentInboxChanged"; @@ -595,6 +611,9 @@ export interface BackgroundCompletion { summary: string | null; stdoutTail: string | null; stderrTail: string | null; + requesterAgentId?: string; + targetAgentId?: string; + conversationId?: string; /** Last-update timestamp (epoch ms); chronological ordering key. */ updatedAtMs: number; } @@ -1096,6 +1115,42 @@ export interface OpenCodeProviderCatalogEntry { models: string[]; } +/** Estimated compatibility for a Codex/Claude model against the detected local CLI. */ +export type ModelCompatibility = + | "compatible" + | "unknown" + | "likelyTooRecent"; + +/** Origin of a model catalogue entry. */ +export type ModelCatalogSource = "catalogue" | "provider"; + +/** One searchable model from the Codex/Claude structured-profile catalogues. */ +export interface ProfileModelCatalogEntry { + /** Structured adapter this model belongs to. */ + adapter: "claude" | "codex"; + /** Exact model identifier to persist on `AgentProfile.model`. */ + modelId: string; + /** Human-readable label for picker display. */ + displayName: string; + /** Extra search tokens useful to the frontend. */ + aliases: string[]; + /** Whether this entry is the conservative default suggestion. */ + recommended: boolean; + /** Best-effort compatibility estimate for the locally detected CLI version. */ + compatibility: ModelCompatibility; + /** Whether the entry comes from IdeA's catalogue or a provider-derived source. */ + source: ModelCatalogSource; +} + +/** Enriched Codex/Claude model catalogue. Manual model entry remains supported. */ +export interface ProfileModelCatalog { + models: ProfileModelCatalogEntry[]; + /** Detected local CLI version, or null when unavailable. */ + cliVersion: string | null; + /** Non-fatal catalogue/version diagnostics. */ + warnings: string[]; +} + /** * A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a * UUID string; `detect` is the optional detection command line. diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 1c12268..c9c78d8 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -237,6 +237,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { // Determine if a template is chosen → profile selector is hidden (template imposes it). const hasTemplate = newTemplateId !== ""; + const profileLabel = (profile: import("@/domain").AgentProfile): string => { + const model = + profile.model ?? + profile.opencode?.model ?? + profile.opencodeProvider?.model ?? + profile.chatHttp?.model; + return model ? `${profile.name} · ${model}` : profile.name; + }; + return ( {vm.error && ( @@ -326,7 +335,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { {vm.profiles.map((p) => ( ))} @@ -366,7 +375,10 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { const isRunning = a.id === activeAgentId; const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id); const profileName = - vm.profiles.find((p) => p.id === a.profileId)?.name ?? + (() => { + const p = vm.profiles.find((p) => p.id === a.profileId); + return p ? profileLabel(p) : null; + })() ?? a.profileId; const agentDrift = drift.driftByAgentId.get(a.id); // Source of this agent's last orchestration delegation (mcp vs @@ -478,7 +490,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { )} {vm.profiles.map((p) => ( ))} diff --git a/frontend/src/features/agents/agents.test.tsx b/frontend/src/features/agents/agents.test.tsx index c7f1232..f1d4338 100644 --- a/frontend/src/features/agents/agents.test.tsx +++ b/frontend/src/features/agents/agents.test.tsx @@ -141,6 +141,42 @@ describe("AgentsPanel (with MockAgentGateway)", () => { expect((btn as HTMLButtonElement).disabled).toBe(true); }); + it("shows profile names with their model in the assignment selector", async () => { + const profile = new MockProfileGateway(); + await profile.saveProfile({ + id: "codex-fast", + name: "Codex fast", + command: "codex", + args: [], + contextInjection: { strategy: "conventionFile", target: "AGENTS.md" }, + detect: "codex --version", + cwdTemplate: "{projectRoot}", + structuredAdapter: "codex", + model: "gpt-5-mini", + }); + await profile.saveProfile({ + id: "claude-opus", + name: "Claude deep", + command: "claude", + args: [], + contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" }, + detect: "claude --version", + cwdTemplate: "{projectRoot}", + structuredAdapter: "claude", + model: "claude-opus-4-8", + }); + + renderPanel(new MockAgentGateway(), profile); + await waitForIdle(); + + const labels = Array.from( + screen.getByLabelText("agent profile").querySelectorAll("option"), + ).map((option) => option.textContent); + + expect(labels).toContain("Codex fast · gpt-5-mini"); + expect(labels).toContain("Claude deep · claude-opus-4-8"); + }); + it("selecting an agent displays its context", async () => { const agent = new MockAgentGateway(); // Pre-seed an agent with initial content. diff --git a/frontend/src/features/first-run/FirstRunWizard.tsx b/frontend/src/features/first-run/FirstRunWizard.tsx index 0c95f8a..edfcb7b 100644 --- a/frontend/src/features/first-run/FirstRunWizard.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.tsx @@ -15,85 +15,34 @@ * `./profile`. */ -import { useCallback, useEffect, useState } from "react"; - import type { AgentProfile, - GatewayError, HttpChatConfig, LocalModelServerConfig, - OpenCodeConfig, - OpenCodeProviderCatalogEntry, } from "@/domain"; -import { useGateways } from "@/app/di"; import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared"; import { ModelServersPanel, - ModelServerSelect, useModelServers, } from "@/features/model-servers"; import { useFirstRun, type WizardEntry } from "./useFirstRun"; import { defaultHttpChatConfig, - defaultOpenCodeConfig, parseArgs, validateProfile, type ProfileErrors, } from "./profile"; +import { + OpenCodeModeFields, + useOpenCodeProviderCatalog, + type OpenCodeProviderCatalog, +} from "./OpenCodeModeFields"; /** A small caption above a control. */ function Caption({ children }: { children: React.ReactNode }) { return {children}; } -function describeError(e: unknown): string { - if (e && typeof e === "object" && "message" in e) { - return String((e as GatewayError).message); - } - return String(e); -} - -/** View-model for the OpenCode cloud-provider catalogue (ticket #92). */ -interface OpenCodeProviderCatalog { - providers: OpenCodeProviderCatalogEntry[] | null; - loading: boolean; - error: string | null; - reload: () => void; -} - -/** - * Loads the static OpenCode cloud-provider catalogue once for the whole - * wizard (every Cloud row shares it), so the provider/model pickers can be - * populated. Exposes a `reload` for the blocking "Réessayer" state. - */ -function useOpenCodeProviderCatalog(): OpenCodeProviderCatalog { - const { profile } = useGateways(); - const [providers, setProviders] = useState( - null, - ); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - setProviders(await profile.listOpenCodeProviders()); - } catch (e) { - setProviders(null); - setError(describeError(e)); - } finally { - setLoading(false); - } - }, [profile]); - - useEffect(() => { - void load(); - }, [load]); - - return { providers, loading, error, reload: () => void load() }; -} - /** * Renders the wizard when it is the first run. Calls `onDone` once the user * finishes (so the host can drop the wizard and show the normal UI). Returns @@ -339,467 +288,6 @@ function ProfileRow({ ); } -/** - * Segmented control (F — ticket #92) choosing whether an OpenCode profile runs - * against the local `llama.cpp` endpoint or a cloud provider from the OpenCode - * registry, and renders the matching sub-form. The two sub-forms never overlap; - * switching segments keeps the inactive one's draft in memory (component-local - * state) without touching `profile` until it is actually submitted. - */ -function OpenCodeModeFields({ - profile, - errors, - servers, - providerCatalog, - onChange, -}: { - profile: AgentProfile; - errors: ProfileErrors; - servers: LocalModelServerConfig[]; - providerCatalog: OpenCodeProviderCatalog; - onChange: (p: AgentProfile) => void; -}) { - const [mode, setMode] = useState<"local" | "cloud">( - profile.opencodeProvider ? "cloud" : "local", - ); - - return ( -
-
- {( - [ - { id: "local", label: "Local (llama.cpp)" }, - { id: "cloud", label: "Provider cloud" }, - ] as const - ).map((seg) => { - const active = mode === seg.id; - return ( - - ); - })} -
- - {mode === "local" && ( - - )} - - {mode === "cloud" && ( - - )} -
- ); -} - -/** Field-keyed validation errors for the Cloud sub-form, surfaced on submit. */ -interface CloudFieldErrors { - providerId?: string; - model?: string; - apiKey?: string; - npm?: string; - baseUrl?: string; -} - -/** Sentinel `