merge feature/multi-profiles-codex-clarification-toast-notices dans develop

Résout le conflit d'imports/helpers de tests dans
crates/application/tests/model_server.rs par union des deux côtés
(imports application/domain + helpers aid/nid/sess).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 13:06:35 +02:00
63 changed files with 5674 additions and 993 deletions

View File

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

View File

@ -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 : `<cli> --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<CliVersion>)` — 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é.

View File

@ -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<String>` (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é).

View File

@ -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é : <raison courte>`.
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é : <taille>.`
- 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.

View File

@ -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.
Quand un agent est un agent opencode, je ne peux aps scroll très haut dans sa cellule.

View File

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

View File

@ -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
Avec les serveurs de models locaux llamacpp, on télécharge des modeles, j'aimerais aussi pouvoir les supprimer

View File

@ -1,3 +1,3 @@
{
"nextNumber": 104
"nextNumber": 107
}

View File

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

View File

@ -41,22 +41,23 @@ use crate::dto::{
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachLiveAgentRequestDto,
AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto,
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto,
OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto,
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
@ -1188,6 +1189,22 @@ pub async fn list_opencode_providers(
Ok(state.list_opencode_providers.execute().into())
}
/// `list_claude_models` — enriched Claude model catalogue.
#[tauri::command]
pub async fn list_claude_models(
state: State<'_, AppState>,
) -> Result<ProfileModelCatalogDto, ErrorDto> {
Ok(state.list_claude_models.execute().await.into())
}
/// `list_codex_models` — enriched Codex model catalogue.
#[tauri::command]
pub async fn list_codex_models(
state: State<'_, AppState>,
) -> Result<ProfileModelCatalogDto, ErrorDto> {
Ok(state.list_codex_models.execute().await.into())
}
/// `save_opencode_provider_profile` — create or replace an OpenCode profile
/// backed by a cloud provider (ticket #92, lot B3). The literal API key is
/// sealed into the `SecretStore`, never persisted in `profiles.json`.
@ -1227,6 +1244,25 @@ pub async fn clone_opencode_profile_from_seed(
.map_err(ErrorDto::from)
}
/// `clone_profile_from_seed` — create a new profile instance from a
/// persisted/reference seed, with optional name/model overrides.
///
/// # Errors
/// Returns an [`ErrorDto`] (`NOT_FOUND` for an unknown seed, `STORE` on profiles
/// I/O failure, `INVALID` for a blank requested name/model).
#[tauri::command]
pub async fn clone_profile_from_seed(
request: CloneProfileFromSeedRequestDto,
state: State<'_, AppState>,
) -> Result<ProfileDto, ErrorDto> {
state
.clone_profile_from_seed
.execute(request.into())
.await
.map(ProfileDto::from)
.map_err(ErrorDto::from)
}
/// `delete_profile` — delete a profile by id.
///
/// # Errors
@ -1303,6 +1339,7 @@ pub async fn save_model_server(
.map_err(ErrorDto::from)?
.servers
.into_iter()
.map(|item| item.config)
.find(|config| config.id == server_id);
let input = save_model_server_input(request, existing.as_ref())?;
state
@ -1352,6 +1389,26 @@ pub async fn delete_model_server(
.map_err(model_server_command_error)
}
/// `delete_model_artifact` — delete a managed downloaded model artifact while
/// keeping the local model-server config.
///
/// # Errors
/// Returns `invalid` for non-managed `localPath` sources, `model_server_in_use`
/// when a download or live agent blocks deletion, and model-server errors for
/// cache I/O failures.
#[tauri::command]
pub async fn delete_model_artifact(
server_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let server_id = parse_model_server_id(&server_id)?;
state
.delete_model_artifact
.execute(application::DeleteModelArtifactInput { server_id })
.await
.map_err(model_server_command_error)
}
fn model_server_command_error(err: AppError) -> ErrorDto {
match err {
AppError::ModelServer { code, message } => ErrorDto { code, message },

View File

@ -255,6 +255,9 @@ pub fn run() {
commands::save_profile,
commands::save_opencode_provider_profile,
commands::list_opencode_providers,
commands::list_claude_models,
commands::list_codex_models,
commands::clone_profile_from_seed,
commands::clone_opencode_profile_from_seed,
commands::delete_profile,
commands::configure_profiles,
@ -262,6 +265,7 @@ pub fn run() {
commands::save_model_server,
commands::preview_model_server_command,
commands::delete_model_server,
commands::delete_model_artifact,
commands::list_embedder_profiles,
commands::save_embedder_profile,
commands::delete_embedder_profile,

View File

@ -132,6 +132,7 @@ fn model_server_dto_preserves_existing_internal_model_id_on_upsert() {
auto_start: true,
stop_policy: StopPolicyDto::StopOnAppExit,
warmup_deadline_secs: Some(900),
artifact: Default::default(),
};
let config = dto.into_domain(Some(&existing)).unwrap();

View File

@ -4,15 +4,17 @@
use app_tauri_lib::dto::{
parse_delete_profile, parse_profile_id, CloneOpenCodeProfileFromSeedRequestDto,
ConfigureProfilesRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
FirstRunStateDto, ProfileListDto, SaveProfileRequestDto,
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, DetectProfilesRequestDto,
DetectProfilesResponseDto, FirstRunStateDto, ProfileListDto, ProfileModelCatalogDto,
SaveProfileRequestDto,
};
use application::{
CloneOpenCodeProfileFromSeedInput, ConfigureProfilesInput, DetectProfilesInput,
DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability, SaveProfileInput,
CloneOpenCodeProfileFromSeedInput, CloneProfileFromSeedInput, ConfigureProfilesInput,
DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability,
SaveProfileInput,
};
use domain::ids::{LocalModelServerId, ProfileId};
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig};
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
use serde_json::json;
use uuid::Uuid;
@ -121,6 +123,50 @@ fn clone_opencode_profile_from_seed_request_deserialises_camelcase_config() {
assert_eq!(opencode.local_model_server_id, Some(server_id));
}
#[test]
fn clone_profile_from_seed_request_deserialises_camelcase_overrides() {
let seed_id = Uuid::from_u128(42);
let raw = json!({
"seedProfileId": seed_id.to_string(),
"name": "Codex GPT-5",
"model": "gpt-5-codex"
});
let dto: CloneProfileFromSeedRequestDto = serde_json::from_value(raw).unwrap();
let input: CloneProfileFromSeedInput = dto.into();
assert_eq!(input.seed_profile_id, ProfileId::from_uuid(seed_id));
assert_eq!(input.name.as_deref(), Some("Codex GPT-5"));
assert_eq!(input.model.as_deref(), Some("gpt-5-codex"));
}
#[test]
fn profile_model_catalogue_dto_serialises_searchable_camelcase_entries() {
let dto = ProfileModelCatalogDto {
models: vec![app_tauri_lib::dto::ProfileModelCatalogEntryDto {
adapter: StructuredAdapter::Codex,
model_id: "gpt-5-codex".to_owned(),
display_name: "GPT-5 Codex".to_owned(),
aliases: vec!["codex".to_owned()],
recommended: true,
compatibility: domain::ModelCompatibility::Compatible,
source: domain::ModelCatalogSource::Catalogue,
}],
cli_version: Some("0.45.1".to_owned()),
warnings: vec!["provider unavailable".to_owned()],
};
let value = serde_json::to_value(&dto).unwrap();
assert_eq!(value["cliVersion"], "0.45.1");
assert_eq!(value["warnings"], json!(["provider unavailable"]));
assert_eq!(value["models"][0]["adapter"], "codex");
assert_eq!(value["models"][0]["modelId"], "gpt-5-codex");
assert_eq!(value["models"][0]["displayName"], "GPT-5 Codex");
assert_eq!(value["models"][0]["aliases"], json!(["codex"]));
assert_eq!(value["models"][0]["recommended"], true);
assert_eq!(value["models"][0]["compatibility"], "compatible");
assert_eq!(value["models"][0]["source"], "catalogue");
}
#[test]
fn opencode_config_dto_omits_local_model_server_id_when_none() {
let config = OpenCodeConfig::new(

View File

@ -9,6 +9,7 @@
mod catalogue;
mod inspect;
mod lifecycle;
mod model_catalogue;
mod provider_catalogue;
mod resume;
mod session_limit;
@ -39,6 +40,10 @@ pub use lifecycle::{
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
};
pub use model_catalogue::{
claude_model_catalogue, codex_model_catalogue, ListClaudeModels, ListClaudeModelsOutput,
ListCodexModels, ListCodexModelsOutput, ProfileModelCatalogEntry,
};
pub use provider_catalogue::{
opencode_models_cache_path, opencode_provider_catalogue, ListOpenCodeProviders,
ListOpenCodeProvidersOutput, OpenCodeProviderCatalogEntry,
@ -48,10 +53,11 @@ pub use resume::{
};
pub use usecases::{
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles,
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles,
ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput,
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput,
CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput,
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput,
FirstRunState, FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability,
ReferenceProfiles, ReferenceProfilesOutput, SaveOpenCodeProviderProfile,
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile,
SaveProfileInput, SaveProfileOutput,
};

View File

@ -0,0 +1,468 @@
//! Curated and best-effort model catalogues for structured Claude/Codex profiles.
//!
//! The CLIs do not expose a stable machine-readable model catalogue and must not
//! be asked to enumerate models. The only local probe allowed here is
//! `<cli> --version`; provider APIs are optional and failures degrade to the
//! static seed plus warnings.
use std::collections::BTreeSet;
use std::sync::Arc;
use domain::model_catalogue::{evaluate_compatibility, CliVersion};
use domain::ports::{CliVersionReader, CompatibilityMatrixSource, ProviderModelCatalogue};
use domain::profile::StructuredAdapter;
use domain::{ModelCatalogSource, ModelCompatibility};
/// One searchable model entry for a structured profile adapter.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileModelCatalogEntry {
/// Structured adapter this model belongs to.
pub adapter: StructuredAdapter,
/// Exact model identifier to persist on [`domain::profile::AgentProfile::model`].
pub model_id: String,
/// Human-readable label for picker display.
pub display_name: String,
/// Extra search tokens useful to the frontend.
pub aliases: Vec<String>,
/// Whether this entry is the conservative default suggestion.
pub recommended: bool,
/// Compatibility state against the locally detected CLI version.
pub compatibility: ModelCompatibility,
/// Source that contributed the model entry.
pub source: ModelCatalogSource,
}
fn entry(
adapter: StructuredAdapter,
model_id: &str,
display_name: &str,
aliases: &[&str],
recommended: bool,
) -> ProfileModelCatalogEntry {
ProfileModelCatalogEntry {
adapter,
model_id: model_id.to_owned(),
display_name: display_name.to_owned(),
aliases: aliases.iter().map(|alias| (*alias).to_owned()).collect(),
recommended,
compatibility: ModelCompatibility::Unknown,
source: ModelCatalogSource::Catalogue,
}
}
fn provider_entry(adapter: StructuredAdapter, model_id: String) -> ProfileModelCatalogEntry {
ProfileModelCatalogEntry {
adapter,
display_name: model_id.clone(),
model_id,
aliases: Vec::new(),
recommended: false,
compatibility: ModelCompatibility::Unknown,
source: ModelCatalogSource::Provider,
}
}
/// Static Claude Code model catalogue.
#[must_use]
pub fn claude_model_catalogue() -> Vec<ProfileModelCatalogEntry> {
vec![
entry(
StructuredAdapter::Claude,
"claude-sonnet-5",
"Claude Sonnet 5",
&["sonnet"],
true,
),
entry(
StructuredAdapter::Claude,
"claude-opus-4-8",
"Claude Opus 4.8",
&["opus"],
false,
),
entry(
StructuredAdapter::Claude,
"claude-haiku-4-5-20251001",
"Claude Haiku 4.5",
&["haiku"],
false,
),
]
}
/// Static OpenAI Codex CLI model catalogue.
#[must_use]
pub fn codex_model_catalogue() -> Vec<ProfileModelCatalogEntry> {
vec![
entry(
StructuredAdapter::Codex,
"gpt-5-codex",
"GPT-5 Codex",
&["codex"],
true,
),
entry(
StructuredAdapter::Codex,
"gpt-5",
"GPT-5",
&["general"],
false,
),
entry(
StructuredAdapter::Codex,
"gpt-5-mini",
"GPT-5 mini",
&["mini", "fast"],
false,
),
]
}
/// Output of structured model-catalogue resolution.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListModelsOutput {
/// The catalogue entries.
pub models: Vec<ProfileModelCatalogEntry>,
/// Best-effort local CLI version.
pub cli_version: Option<CliVersion>,
/// Non-fatal fallback/degradation warnings.
pub warnings: Vec<String>,
}
/// Output of [`ListClaudeModels::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListClaudeModelsOutput {
/// The catalogue entries.
pub models: Vec<ProfileModelCatalogEntry>,
/// Best-effort local CLI version.
pub cli_version: Option<CliVersion>,
/// Non-fatal fallback/degradation warnings.
pub warnings: Vec<String>,
}
impl From<ListModelsOutput> for ListClaudeModelsOutput {
fn from(out: ListModelsOutput) -> Self {
Self {
models: out.models,
cli_version: out.cli_version,
warnings: out.warnings,
}
}
}
/// Output of [`ListCodexModels::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListCodexModelsOutput {
/// The catalogue entries.
pub models: Vec<ProfileModelCatalogEntry>,
/// Best-effort local CLI version.
pub cli_version: Option<CliVersion>,
/// Non-fatal fallback/degradation warnings.
pub warnings: Vec<String>,
}
impl From<ListModelsOutput> for ListCodexModelsOutput {
fn from(out: ListModelsOutput) -> Self {
Self {
models: out.models,
cli_version: out.cli_version,
warnings: out.warnings,
}
}
}
/// Use case resolving an enriched structured model catalogue.
pub struct ResolveModelCatalogue {
cli_versions: Arc<dyn CliVersionReader>,
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
matrix_source: Arc<dyn CompatibilityMatrixSource>,
}
impl ResolveModelCatalogue {
/// Builds the use case from hexagonal ports.
#[must_use]
pub fn new(
cli_versions: Arc<dyn CliVersionReader>,
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
matrix_source: Arc<dyn CompatibilityMatrixSource>,
) -> Self {
Self {
cli_versions,
provider_catalogue,
matrix_source,
}
}
/// Resolves models for one structured adapter. Infallible by design.
pub async fn execute(&self, adapter: StructuredAdapter) -> ListModelsOutput {
let mut warnings = Vec::new();
let (matrix, matrix_warnings) = self.matrix_source.compatibility_matrix();
warnings.extend(matrix_warnings);
let cli_version = match self.cli_versions.read_cli_version(adapter).await {
Ok(version) => version,
Err(warning) => {
warnings.push(warning);
None
}
};
let mut models = match adapter {
StructuredAdapter::Claude => claude_model_catalogue(),
StructuredAdapter::Codex => codex_model_catalogue(),
StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => Vec::new(),
};
match self.provider_catalogue.list_provider_models(adapter).await {
Ok(provider_models) => {
let existing = models
.iter()
.map(|model| model.model_id.clone())
.collect::<BTreeSet<_>>();
models.extend(
provider_models
.into_iter()
.filter(|model_id| !existing.contains(model_id))
.map(|model_id| provider_entry(adapter, model_id)),
);
}
Err(warning) => warnings.push(warning),
}
for model in &mut models {
model.compatibility =
evaluate_compatibility(&matrix, adapter, &model.model_id, cli_version.as_ref());
}
models.sort_by(|a, b| {
b.recommended
.cmp(&a.recommended)
.then_with(|| a.display_name.cmp(&b.display_name))
.then_with(|| a.model_id.cmp(&b.model_id))
});
ListModelsOutput {
models,
cli_version,
warnings,
}
}
}
/// Use case exposing the Claude model catalogue.
pub struct ListClaudeModels {
resolver: ResolveModelCatalogue,
}
impl ListClaudeModels {
/// Builds the use case.
#[must_use]
pub fn new(
cli_versions: Arc<dyn CliVersionReader>,
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
matrix_source: Arc<dyn CompatibilityMatrixSource>,
) -> Self {
Self {
resolver: ResolveModelCatalogue::new(cli_versions, provider_catalogue, matrix_source),
}
}
/// Lists Claude models. Infallible.
pub async fn execute(&self) -> ListClaudeModelsOutput {
self.resolver
.execute(StructuredAdapter::Claude)
.await
.into()
}
}
/// Use case exposing the Codex model catalogue.
pub struct ListCodexModels {
resolver: ResolveModelCatalogue,
}
impl ListCodexModels {
/// Builds the use case.
#[must_use]
pub fn new(
cli_versions: Arc<dyn CliVersionReader>,
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
matrix_source: Arc<dyn CompatibilityMatrixSource>,
) -> Self {
Self {
resolver: ResolveModelCatalogue::new(cli_versions, provider_catalogue, matrix_source),
}
}
/// Lists Codex models. Infallible.
pub async fn execute(&self) -> ListCodexModelsOutput {
self.resolver.execute(StructuredAdapter::Codex).await.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use domain::{CliVersion, CompatibilityMatrix};
use std::collections::HashMap;
struct FakeCliVersionReader(Option<Result<Option<CliVersion>, String>>);
#[async_trait]
impl CliVersionReader for FakeCliVersionReader {
async fn read_cli_version(
&self,
_adapter: StructuredAdapter,
) -> Result<Option<CliVersion>, String> {
self.0
.clone()
.unwrap_or_else(|| Ok(Some(CliVersion::parse("1.0.0").unwrap())))
}
}
struct FakeProvider(Vec<String>, Option<String>);
#[async_trait]
impl ProviderModelCatalogue for FakeProvider {
async fn list_provider_models(
&self,
_adapter: StructuredAdapter,
) -> Result<Vec<String>, String> {
if let Some(warning) = &self.1 {
Err(warning.clone())
} else {
Ok(self.0.clone())
}
}
}
struct FakeMatrixSource(CompatibilityMatrix, Vec<String>);
impl CompatibilityMatrixSource for FakeMatrixSource {
fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec<String>) {
(self.0.clone(), self.1.clone())
}
}
fn resolver(
version: Option<Result<Option<CliVersion>, String>>,
provider: Vec<String>,
provider_warning: Option<String>,
) -> ResolveModelCatalogue {
ResolveModelCatalogue::new(
Arc::new(FakeCliVersionReader(version)),
Arc::new(FakeProvider(provider, provider_warning)),
Arc::new(FakeMatrixSource(
CompatibilityMatrix {
version: 1,
claude: HashMap::from([
("claude-sonnet-5".to_owned(), "1.0.0".to_owned()),
("claude-opus-4-8".to_owned(), "2.0.0".to_owned()),
]),
codex: HashMap::from([("gpt-5-codex".to_owned(), "1.0.0".to_owned())]),
},
vec![],
)),
)
}
#[test]
fn static_catalogues_are_non_empty_searchable_and_have_one_default() {
for (adapter, models) in [
(StructuredAdapter::Claude, claude_model_catalogue()),
(StructuredAdapter::Codex, codex_model_catalogue()),
] {
assert!(!models.is_empty());
assert_eq!(
models.iter().filter(|model| model.recommended).count(),
1,
"{adapter:?} should expose one default suggestion"
);
for model in models {
assert_eq!(model.adapter, adapter);
assert!(!model.model_id.trim().is_empty());
assert!(!model.display_name.trim().is_empty());
assert_eq!(model.compatibility, ModelCompatibility::Unknown);
assert_eq!(model.source, ModelCatalogSource::Catalogue);
}
}
}
#[tokio::test]
async fn resolver_enriches_static_catalogue_with_cli_compatibility() {
let out = resolver(
Some(Ok(Some(CliVersion::parse("1.0.0").unwrap()))),
vec![],
None,
)
.execute(StructuredAdapter::Claude)
.await;
let sonnet = out
.models
.iter()
.find(|model| model.model_id == "claude-sonnet-5")
.unwrap();
let opus = out
.models
.iter()
.find(|model| model.model_id == "claude-opus-4-8")
.unwrap();
assert_eq!(sonnet.compatibility, ModelCompatibility::Compatible);
assert_eq!(opus.compatibility, ModelCompatibility::LikelyTooRecent);
assert_eq!(out.cli_version.unwrap().raw, "1.0.0");
assert!(out.warnings.is_empty());
}
#[tokio::test]
async fn resolver_keeps_provider_and_cli_failures_non_blocking() {
let out = resolver(
Some(Err("codex version unavailable".to_owned())),
vec![],
Some("provider unavailable".to_owned()),
)
.execute(StructuredAdapter::Codex)
.await;
assert!(!out.models.is_empty());
assert_eq!(out.cli_version, None);
assert_eq!(
out.warnings,
vec![
"codex version unavailable".to_owned(),
"provider unavailable".to_owned()
]
);
assert!(out
.models
.iter()
.all(|model| model.compatibility == ModelCompatibility::Unknown));
}
#[tokio::test]
async fn resolver_adds_provider_only_models_without_duplicate_seed_entries() {
let out = resolver(
None,
vec!["gpt-5-codex".to_owned(), "gpt-5-provider".to_owned()],
None,
)
.execute(StructuredAdapter::Codex)
.await;
assert_eq!(
out.models
.iter()
.filter(|model| model.model_id == "gpt-5-codex")
.count(),
1
);
let provider = out
.models
.iter()
.find(|model| model.model_id == "gpt-5-provider")
.unwrap();
assert_eq!(provider.source, ModelCatalogSource::Provider);
assert_eq!(provider.compatibility, ModelCompatibility::Unknown);
}
}

View File

@ -146,6 +146,92 @@ pub struct SaveProfileOutput {
pub profile: AgentProfile,
}
// ---------------------------------------------------------------------------
// CloneProfileFromSeed
// ---------------------------------------------------------------------------
/// Input for [`CloneProfileFromSeed::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloneProfileFromSeedInput {
/// Id of the persisted or reference profile to clone.
pub seed_profile_id: ProfileId,
/// Optional display name for the cloned profile. When absent, a copy label is
/// derived from the seed name.
pub name: Option<String>,
/// Optional model override. When absent, the seed model is copied as-is.
pub model: Option<String>,
}
/// Output of [`CloneProfileFromSeed::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloneProfileFromSeedOutput {
/// The newly persisted profile.
pub profile: AgentProfile,
}
/// Creates a new profile instance from an existing persisted/reference seed.
///
/// Persisted profiles are preferred over reference seeds so user edits to the
/// seed are preserved. The clone always receives a fresh [`ProfileId`] from the
/// backend [`IdGenerator`]; callers can override the display name and model
/// without minting ids client-side.
pub struct CloneProfileFromSeed {
store: Arc<dyn ProfileStore>,
ids: Arc<dyn IdGenerator>,
}
impl CloneProfileFromSeed {
/// Builds the use case from the profile store and id generator ports.
#[must_use]
pub fn new(store: Arc<dyn ProfileStore>, ids: Arc<dyn IdGenerator>) -> Self {
Self { store, ids }
}
/// Clones the requested seed into a new persisted profile.
///
/// # Errors
/// [`AppError::NotFound`] if no persisted/reference profile has the seed id,
/// [`AppError::Invalid`] if `name` or `model` is blank, [`AppError::Store`]
/// on persistence failure.
pub async fn execute(
&self,
input: CloneProfileFromSeedInput,
) -> Result<CloneProfileFromSeedOutput, AppError> {
let existing = self.store.list().await?;
let seed = existing
.iter()
.find(|profile| profile.id == input.seed_profile_id)
.cloned()
.or_else(|| {
reference_profiles()
.into_iter()
.find(|profile| profile.id == input.seed_profile_id)
})
.ok_or(AppError::NotFound("profile seed not found".into()))?;
let mut profile = seed;
profile.id = fresh_profile_id(&*self.ids, &existing)?;
profile.name = match input.name {
Some(name) => {
if name.trim().is_empty() {
return Err(AppError::Invalid("profile.name must not be empty".into()));
}
name
}
None => format!("{} copy", profile.name),
};
if let Some(model) = input.model {
if model.trim().is_empty() {
return Err(AppError::Invalid("profile.model must not be empty".into()));
}
profile.model = Some(model);
}
self.store.save(&profile).await?;
Ok(CloneProfileFromSeedOutput { profile })
}
}
// ---------------------------------------------------------------------------
// CloneOpenCodeProfileFromSeed
// ---------------------------------------------------------------------------

View File

@ -40,21 +40,23 @@ pub mod window;
pub mod workstate;
pub use agent::{
drain_reply_stream_with_readiness, drain_with_readiness,
drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id,
reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer,
AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, ListOpenCodeProviders, ListOpenCodeProvidersOutput, ListProfiles,
ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput,
LiveStateLeanProvider, McpRuntime, OpenCodeProviderCatalogEntry, PermissionProjectorRegistry,
ProfileAvailability, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
claude_model_catalogue, codex_model_catalogue, drain_reply_stream_with_readiness,
drain_with_readiness, drain_with_readiness_and_announcements, drain_with_readiness_outcome,
reference_profile_id, reference_profiles, selectable_reference_profiles, send_blocking,
AgentResumer, AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput,
ChangeAgentProfileOutput, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput,
CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput,
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput,
FirstRunState, FirstRunStateOutput, HandoffProvider, InjectedLiveRow, InspectConversation,
InspectConversationInput, InspectConversationOutput, LaunchAgent, LaunchAgentInput,
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListClaudeModels,
ListClaudeModelsOutput, ListCodexModels, ListCodexModelsOutput, ListOpenCodeProviders,
ListOpenCodeProvidersOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
OpenCodeProviderCatalogEntry, PermissionProjectorRegistry, ProfileAvailability,
ProfileModelCatalogEntry, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent,
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
@ -125,10 +127,12 @@ pub use memory::{
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
};
pub use model_server::{
model_server_error_code, DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer,
EnsureLocalModelServerInput, EnsureLocalModelServerOutput, ListModelServers,
ListModelServersOutput, ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer,
SaveModelServerInput, SaveModelServerOutput,
model_server_error_code, DeleteModelArtifact, DeleteModelArtifactInput, DeleteModelServer,
DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput,
EnsureLocalModelServerOutput, ListModelServers, ListModelServersOutput,
ModelArtifactDownloadTracker, ModelArtifactView, ModelServerListItem,
ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer, SaveModelServerInput,
SaveModelServerOutput,
};
pub use orchestrator::{
resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog,

View File

@ -11,33 +11,101 @@ use domain::model_server::{
ModelSource,
};
use domain::ports::{
EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel,
ModelArtifactDownloader, ModelArtifactProgress, ModelServerError, ModelServerProbe,
ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore, RemotePath,
AgentContextStore, EventBus, FileSystem, ManagedProcess, ManagedProcessHandle,
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactState,
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
ProfileStore, ProjectStore, RemotePath,
};
use domain::{LocalModelServerId, ProjectId, StopPolicy};
use tokio::sync::{Mutex as AsyncMutex, Notify};
use tokio::time::Instant;
use crate::error::AppError;
use crate::terminal::LiveAgentRegistry;
/// Artifact cache state exposed by model-server list use cases.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelArtifactView {
/// The configured source is not managed by IdeA's downloader.
NotManaged,
/// The configured source is managed but not present in cache.
Missing,
/// A download/prepare operation is currently running for this server.
Downloading,
/// The configured source is present in cache.
Downloaded {
/// Local artifact path used by llama.cpp.
path: String,
/// Total on-disk size when known.
size_bytes: Option<u64>,
},
}
impl From<ModelArtifactState> for ModelArtifactView {
fn from(state: ModelArtifactState) -> Self {
match state {
ModelArtifactState::NotManaged => Self::NotManaged,
ModelArtifactState::Missing => Self::Missing,
ModelArtifactState::Downloaded { path, size_bytes } => Self::Downloaded {
path: path.as_str().to_owned(),
size_bytes,
},
}
}
}
/// A configured local model server plus derived artifact state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelServerListItem {
/// Persisted local model-server config.
pub config: LocalModelServerConfig,
/// Derived artifact cache state.
pub artifact: ModelArtifactView,
}
/// Output of [`ListModelServers::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListModelServersOutput {
/// Persisted local model-server configs.
pub servers: Vec<LocalModelServerConfig>,
/// Persisted local model-server configs enriched with artifact state.
pub servers: Vec<ModelServerListItem>,
}
/// Lists local model-server configurations.
pub struct ListModelServers {
registry: Arc<dyn ModelServerRegistry>,
downloader: Option<Arc<dyn ModelArtifactDownloader>>,
downloads: Option<Arc<dyn ModelArtifactDownloadTracker>>,
}
impl ListModelServers {
/// Builds the use case.
#[must_use]
pub fn new(registry: Arc<dyn ModelServerRegistry>) -> Self {
Self { registry }
Self {
registry,
downloader: None,
downloads: None,
}
}
/// Enables artifact state enrichment for Hugging Face-backed servers.
#[must_use]
pub fn with_model_artifact_downloader(
mut self,
downloader: Arc<dyn ModelArtifactDownloader>,
) -> Self {
self.downloader = Some(downloader);
self
}
/// Enables in-flight download state enrichment.
#[must_use]
pub fn with_download_tracker(
mut self,
downloads: Arc<dyn ModelArtifactDownloadTracker>,
) -> Self {
self.downloads = Some(downloads);
self
}
/// Lists configs.
@ -45,10 +113,44 @@ impl ListModelServers {
/// # Errors
/// [`AppError::ModelServer`] on registry failure.
pub async fn execute(&self) -> Result<ListModelServersOutput, AppError> {
Ok(ListModelServersOutput {
servers: self.registry.list().await?,
})
let configs = self.registry.list().await?;
let mut servers = Vec::with_capacity(configs.len());
for config in configs {
let artifact = self.artifact_view(&config).await?;
servers.push(ModelServerListItem { config, artifact });
}
Ok(ListModelServersOutput { servers })
}
async fn artifact_view(
&self,
config: &LocalModelServerConfig,
) -> Result<ModelArtifactView, AppError> {
if self
.downloads
.as_ref()
.is_some_and(|downloads| downloads.is_model_artifact_download_in_progress(config.id))
{
return Ok(ModelArtifactView::Downloading);
}
let Some(ModelSource::HuggingFace { repo }) = config.model.source.as_ref() else {
return Ok(ModelArtifactView::NotManaged);
};
let Some(downloader) = self.downloader.as_ref() else {
return Ok(ModelArtifactView::Missing);
};
downloader
.hf_model_state(repo)
.await
.map(ModelArtifactView::from)
.map_err(AppError::from)
}
}
/// Read-only in-flight download state shared by list/delete use cases.
pub trait ModelArtifactDownloadTracker: Send + Sync {
/// Whether the model artifact for `server_id` is currently being resolved/downloaded.
fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool;
}
/// Input for [`SaveModelServer::execute`].
@ -132,6 +234,154 @@ impl DeleteModelServer {
}
}
/// Input for [`DeleteModelArtifact::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteModelArtifactInput {
/// Config id whose managed artifact cache should be deleted.
pub server_id: LocalModelServerId,
}
/// Deletes a downloaded model artifact without deleting the server config.
pub struct DeleteModelArtifact {
registry: Arc<dyn ModelServerRegistry>,
probe: Arc<dyn ModelServerProbe>,
downloader: Arc<dyn ModelArtifactDownloader>,
downloads: Arc<dyn ModelArtifactDownloadTracker>,
profiles: Arc<dyn ProfileStore>,
projects: Arc<dyn ProjectStore>,
contexts: Arc<dyn AgentContextStore>,
live: Arc<dyn LiveAgentRegistry>,
}
impl DeleteModelArtifact {
/// Builds the use case.
#[allow(clippy::too_many_arguments)]
#[must_use]
pub fn new(
registry: Arc<dyn ModelServerRegistry>,
probe: Arc<dyn ModelServerProbe>,
downloader: Arc<dyn ModelArtifactDownloader>,
downloads: Arc<dyn ModelArtifactDownloadTracker>,
profiles: Arc<dyn ProfileStore>,
projects: Arc<dyn ProjectStore>,
contexts: Arc<dyn AgentContextStore>,
live: Arc<dyn LiveAgentRegistry>,
) -> Self {
Self {
registry,
probe,
downloader,
downloads,
profiles,
projects,
contexts,
live,
}
}
/// Deletes a managed Hugging Face artifact after safety checks.
///
/// # Errors
/// [`AppError::ModelServer`] when the server is missing, the source is not
/// deletable, a download is active, or a live agent uses the server.
pub async fn execute(&self, input: DeleteModelArtifactInput) -> Result<(), AppError> {
let config = self
.registry
.get(&input.server_id)
.await?
.ok_or(ModelServerError::NotConfigured)?;
let Some(ModelSource::HuggingFace { repo }) = config.model.source.as_ref() else {
return Err(ModelServerError::Invalid(
"only managed Hugging Face model artifacts can be deleted".to_owned(),
)
.into());
};
if self
.downloads
.is_model_artifact_download_in_progress(input.server_id)
{
return Err(ModelServerError::InUse(format!(
"model artifact download in progress for {}",
input.server_id
))
.into());
}
self.ensure_server_not_reachable(&config).await?;
self.ensure_not_used_by_live_agent(input.server_id).await?;
self.downloader.delete_hf_model(repo).await?;
Ok(())
}
async fn ensure_server_not_reachable(
&self,
config: &LocalModelServerConfig,
) -> Result<(), AppError> {
match self.probe.probe(&config.endpoint).await? {
ModelServerStatus::Unreachable => Ok(()),
ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted => {
Err(ModelServerError::InUse(format!(
"model server {} is currently reachable",
config.id
))
.into())
}
}
}
async fn ensure_not_used_by_live_agent(
&self,
server_id: LocalModelServerId,
) -> Result<(), AppError> {
let profiles = self.profiles.list().await?;
let profile_server: HashMap<_, _> = profiles
.iter()
.filter_map(|profile| {
profile
.opencode
.as_ref()
.and_then(|opencode| opencode.local_model_server_id)
.map(|id| (profile.id, id))
})
.collect();
let mut agents_by_project = HashMap::new();
for snapshot in self.live.live_agent_snapshots() {
let agents = if let Some(agents) = agents_by_project.get(&snapshot.project_id) {
agents
} else {
let project = self.projects.load_project(snapshot.project_id).await?;
let manifest = self.contexts.load_manifest(&project).await?;
agents_by_project.insert(
snapshot.project_id,
manifest
.entries
.iter()
.map(|entry| {
entry
.to_agent()
.map_err(|err| AppError::Invalid(err.to_string()))
})
.collect::<Result<Vec<_>, _>>()?,
);
agents_by_project
.get(&snapshot.project_id)
.expect("project agents inserted")
};
let Some(agent) = agents.iter().find(|agent| agent.id == snapshot.agent_id) else {
continue;
};
if profile_server.get(&agent.profile_id) == Some(&server_id) {
return Err(ModelServerError::InUse(format!(
"model server {server_id} is used by live agent {}",
agent.id
))
.into());
}
}
Ok(())
}
}
/// Input for [`EnsureLocalModelServer::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnsureLocalModelServerInput {
@ -738,6 +988,15 @@ impl EnsureLocalModelServer {
}
}
impl ModelArtifactDownloadTracker for EnsureLocalModelServer {
fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool {
self.download_cancels
.lock()
.unwrap()
.contains_key(&server_id)
}
}
fn ready(config: &LocalModelServerConfig, status: ModelServerStatus) -> ModelServerReady {
ModelServerReady {
base_url: config.endpoint.base_url.clone(),

View File

@ -127,6 +127,22 @@ fn resolve_background_cwd(
ProjectPath::new(path).map_err(|_| AppError::Invalid("invalid background task cwd".to_owned()))
}
fn rendezvous_context_for_task(task: &BackgroundTask) -> Option<domain::RendezvousContext> {
match &task.kind {
BackgroundTaskKind::HeadlessRendezvous {
requester_agent_id,
target_agent_id,
conversation_id,
..
} => Some(domain::RendezvousContext {
requester_agent_id: *requester_agent_id,
target_agent_id: *target_agent_id,
conversation_id: *conversation_id,
}),
_ => None,
}
}
fn normalize_path_no_parent(path: &Path) -> Result<PathBuf, AppError> {
let mut out = PathBuf::new();
for component in path.components() {
@ -847,22 +863,26 @@ impl OrchestratorService {
if task.is_terminal() {
return Ok(());
}
let rendezvous = rendezvous_context_for_task(&task);
let event = match &result {
BackgroundTaskResult::Success { .. } => DomainEvent::BackgroundTaskCompleted {
project_id: project.id,
task_id,
owner_agent_id,
rendezvous,
},
BackgroundTaskResult::Failure { .. } => DomainEvent::BackgroundTaskFailed {
project_id: project.id,
task_id,
owner_agent_id,
rendezvous,
},
BackgroundTaskResult::Cancelled { .. } | BackgroundTaskResult::Expired { .. } => {
DomainEvent::BackgroundTaskCancelled {
project_id: project.id,
task_id,
owner_agent_id,
rendezvous,
}
}
};
@ -2967,6 +2987,52 @@ mod tests {
assert_eq!(submit.delay_ms, Some(CODEX_SUBMIT_DELAY_MS));
}
#[test]
fn rendezvous_context_is_extracted_from_headless_background_task_kind() {
let requester = aid(1);
let target = aid(2);
let conversation_id = domain::ConversationId::from_uuid(uuid::Uuid::from_u128(3));
let task = BackgroundTask::new(
TaskId::from_uuid(uuid::Uuid::from_u128(4)),
domain::ProjectId::from_uuid(uuid::Uuid::from_u128(5)),
target,
BackgroundTaskKind::HeadlessRendezvous {
requester_agent_id: Some(requester),
target_agent_id: target,
ticket_id: TicketId::from_uuid(uuid::Uuid::from_u128(6)),
conversation_id,
},
BackgroundTaskWakePolicy::RecordOnly,
100,
None,
)
.unwrap();
let context = rendezvous_context_for_task(&task).expect("rendezvous context");
assert_eq!(context.requester_agent_id, Some(requester));
assert_eq!(context.target_agent_id, target);
assert_eq!(context.conversation_id, conversation_id);
}
#[test]
fn rendezvous_context_is_absent_for_command_background_task() {
let task = BackgroundTask::new(
TaskId::from_uuid(uuid::Uuid::from_u128(7)),
domain::ProjectId::from_uuid(uuid::Uuid::from_u128(8)),
aid(9),
BackgroundTaskKind::Command {
label: "cargo test".to_owned(),
},
BackgroundTaskWakePolicy::RecordOnly,
100,
None,
)
.unwrap();
assert_eq!(rendezvous_context_for_task(&task), None);
}
#[test]
fn explicit_profile_submit_delay_is_preserved() {
let p = profile(3, "OpenAI Codex CLI", "codex")

View File

@ -67,6 +67,11 @@ pub trait LiveAgentRegistry: Send + Sync {
/// be keyed on the hosting node, not the agent (otherwise a duplicate leaf
/// would be wrongly marked as still running).
fn is_node_live(&self, node_id: &NodeId) -> bool;
/// Snapshots every live agent session currently known by this registry.
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
Vec::new()
}
}
/// In-memory registry of active terminal sessions.
@ -92,6 +97,26 @@ impl LiveAgentRegistry for TerminalSessions {
.map(|m| m.values().any(|e| e.session.node_id == *node_id))
.unwrap_or(false)
}
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
self.entries
.lock()
.map(|m| {
m.values()
.filter_map(|e| match e.session.kind {
SessionKind::Agent { agent_id } => Some(LiveSessionSnapshot {
project_id: e.project_id,
agent_id,
node_id: e.session.node_id,
session_id: e.session.id,
kind: LiveSessionKind::Pty,
}),
SessionKind::Plain => None,
})
.collect()
})
.unwrap_or_default()
}
}
impl TerminalSessions {
@ -444,6 +469,23 @@ impl LiveAgentRegistry for StructuredSessions {
.map(|m| m.values().any(|e| e.node_id == *node_id))
.unwrap_or(false)
}
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
self.entries
.lock()
.map(|m| {
m.values()
.map(|e| LiveSessionSnapshot {
project_id: e.project_id,
agent_id: e.agent_id,
node_id: e.node_id,
session_id: e.session.id(),
kind: LiveSessionKind::Structured,
})
.collect()
})
.unwrap_or_default()
}
}
impl StructuredSessions {
@ -853,42 +895,8 @@ impl LiveSessions {
/// Tous les agents vivants avec le type de registre source (PTY puis structuré).
#[must_use]
pub fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
let mut all: Vec<LiveSessionSnapshot> = self
.pty
.entries
.lock()
.map(|m| {
m.values()
.filter_map(|e| match e.session.kind {
SessionKind::Agent { agent_id } => Some(LiveSessionSnapshot {
project_id: e.project_id,
agent_id,
node_id: e.session.node_id,
session_id: e.session.id,
kind: LiveSessionKind::Pty,
}),
SessionKind::Plain => None,
})
.collect()
})
.unwrap_or_default();
all.extend(
self.structured
.entries
.lock()
.map(|m| {
m.values()
.map(|e| LiveSessionSnapshot {
project_id: e.project_id,
agent_id: e.agent_id,
node_id: e.node_id,
session_id: e.session.id(),
kind: LiveSessionKind::Structured,
})
.collect::<Vec<_>>()
})
.unwrap_or_default(),
);
let mut all = self.pty.live_agent_snapshots();
all.extend(self.structured.live_agent_snapshots());
all
}
}
@ -902,4 +910,8 @@ impl LiveAgentRegistry for LiveSessions {
fn is_node_live(&self, node_id: &NodeId) -> bool {
self.pty.is_node_live(node_id) || self.structured.is_node_live(node_id)
}
fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
LiveSessions::live_agent_snapshots(self)
}
}

View File

@ -231,6 +231,12 @@ pub struct AgentBackgroundTaskState {
pub stdout_tail: Option<String>,
/// Bounded stderr tail.
pub stderr_tail: Option<String>,
/// Agent that requested a headless rendezvous, when this task is one.
pub requester_agent_id: Option<AgentId>,
/// Target agent for a headless rendezvous, when this task is one.
pub target_agent_id: Option<AgentId>,
/// Conversation opened by a headless rendezvous, when this task is one.
pub conversation_id: Option<domain::ConversationId>,
/// Creation timestamp, epoch milliseconds.
pub created_at_ms: u64,
/// Last update timestamp, epoch milliseconds.
@ -592,6 +598,8 @@ impl GetProjectWorkState {
impl From<BackgroundTask> for AgentBackgroundTaskState {
fn from(task: BackgroundTask) -> Self {
let (exit_code, summary, stdout_tail, stderr_tail) = flatten_background_result(&task);
let (requester_agent_id, target_agent_id, conversation_id) =
flatten_background_rendezvous_context(&task.kind);
Self {
task_id: task.id,
kind: BackgroundTaskKindLabel::from(&task.kind),
@ -600,12 +608,37 @@ impl From<BackgroundTask> for AgentBackgroundTaskState {
summary,
stdout_tail,
stderr_tail,
requester_agent_id,
target_agent_id,
conversation_id,
created_at_ms: task.created_at_ms,
updated_at_ms: task.updated_at_ms,
}
}
}
fn flatten_background_rendezvous_context(
kind: &BackgroundTaskKind,
) -> (
Option<AgentId>,
Option<AgentId>,
Option<domain::ConversationId>,
) {
match kind {
BackgroundTaskKind::HeadlessRendezvous {
requester_agent_id,
target_agent_id,
conversation_id,
..
} => (
*requester_agent_id,
Some(*target_agent_id),
Some(*conversation_id),
),
_ => (None, None, None),
}
}
impl From<&BackgroundTaskKind> for BackgroundTaskKindLabel {
fn from(kind: &BackgroundTaskKind) -> Self {
match kind {

View File

@ -7,35 +7,52 @@ use std::time::Duration;
use async_trait::async_trait;
use application::{
DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput,
ModelServerReadinessPolicy, TerminalSessions,
DeleteModelArtifact, DeleteModelArtifactInput, DeleteModelServer, DeleteModelServerInput,
EnsureLocalModelServer, EnsureLocalModelServerInput, LiveAgentRegistry, LiveSessionKind,
LiveSessionSnapshot, ModelArtifactDownloadTracker, ModelServerReadinessPolicy, TerminalSessions,
};
use domain::events::DomainEvent;
use domain::layout::Workspace;
use domain::markdown::MarkdownDoc;
use domain::model_server::{
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelServerLifecycleStatus,
ModelServerStatus, ModelSource, StopPolicy,
};
use domain::ports::{
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
ModelServerArgv, ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime,
ProcessStatus, ProfileStore, RemotePath, SpawnSpec, StoreError,
AgentContextStore, DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess,
ManagedProcessHandle, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
ModelArtifactResolution, ModelArtifactState, ModelServerArgv, ModelServerError,
ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore,
ProjectStore, RemotePath, SpawnSpec, StoreError,
};
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
use domain::project::Project;
use domain::{
AgentId, LocalModelServerId, NodeId, ProfileId, ProjectId, ProjectPath, PtySize, SessionId,
SessionKind, SessionStatus, TerminalSession,
AgentId, AgentManifest, LocalModelServerId, ManifestEntry, NodeId, ProfileId, ProjectId,
ProjectPath, PtySize, RemoteRef, SessionId, SessionKind, SessionStatus, TerminalSession,
};
fn sid(n: u128) -> LocalModelServerId {
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
}
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn pid(n: u128) -> ProjectId {
ProjectId::from_uuid(uuid::Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(uuid::Uuid::from_u128(n))
}
fn sess(n: u128) -> SessionId {
SessionId::from_uuid(uuid::Uuid::from_u128(n))
}
fn config(
id: LocalModelServerId,
port: u16,
@ -301,18 +318,27 @@ enum FakeDownloadOutcome {
struct FakeModelArtifactDownloader {
outcome: Mutex<FakeDownloadOutcome>,
deleted: Mutex<Vec<String>>,
}
impl FakeModelArtifactDownloader {
fn new(outcome: FakeDownloadOutcome) -> Self {
Self {
outcome: Mutex::new(outcome),
deleted: Mutex::new(Vec::new()),
}
}
}
#[async_trait]
impl ModelArtifactDownloader for FakeModelArtifactDownloader {
async fn hf_model_state(
&self,
_repo: &HfModelRef,
) -> Result<ModelArtifactState, ModelServerError> {
Ok(ModelArtifactState::Missing)
}
async fn resolve_hf_model(
&self,
repo: &HfModelRef,
@ -354,6 +380,123 @@ impl ModelArtifactDownloader for FakeModelArtifactDownloader {
}
}
}
async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
self.deleted.lock().unwrap().push(repo.as_str().to_owned());
Ok(())
}
}
#[derive(Default)]
struct FakeDownloadTracker {
in_progress: Mutex<Vec<LocalModelServerId>>,
}
impl ModelArtifactDownloadTracker for FakeDownloadTracker {
fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool {
self.in_progress.lock().unwrap().contains(&server_id)
}
}
#[derive(Default)]
struct FakeLive {
snapshots: Vec<LiveSessionSnapshot>,
}
impl LiveAgentRegistry for FakeLive {
fn is_agent_live(&self, _project_id: ProjectId, _agent_id: &AgentId) -> bool {
false
}
fn is_node_live(&self, _node_id: &NodeId) -> bool {
false
}
fn live_agent_snapshots(&self) -> Vec<application::LiveSessionSnapshot> {
self.snapshots.clone()
}
}
struct FakeProjects {
project_id: ProjectId,
}
impl Default for FakeProjects {
fn default() -> Self {
Self { project_id: pid(1) }
}
}
#[async_trait]
impl ProjectStore for FakeProjects {
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
Ok(Vec::new())
}
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
if id != self.project_id {
return Err(StoreError::NotFound);
}
Project::new(
id,
"Test",
ProjectPath::new("/tmp/unused").unwrap(),
RemoteRef::Local,
0,
)
.map_err(|err| StoreError::Invalid(err.to_string()))
}
async fn save_project(&self, _project: &Project) -> Result<(), StoreError> {
Ok(())
}
async fn save_workspace(&self, _workspace: &Workspace) -> Result<(), StoreError> {
Ok(())
}
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
Ok(Workspace {
windows: Vec::new(),
})
}
}
#[derive(Default)]
struct FakeContexts {
manifest: AgentManifest,
}
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_project: &Project,
_agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
Ok(MarkdownDoc::new(""))
}
async fn write_context(
&self,
_project: &Project,
_agent: &AgentId,
_md: &MarkdownDoc,
) -> Result<(), StoreError> {
Ok(())
}
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
Ok(self.manifest.clone())
}
async fn save_manifest(
&self,
_project: &Project,
_manifest: &AgentManifest,
) -> Result<(), StoreError> {
Ok(())
}
}
#[derive(Default)]
@ -434,6 +577,46 @@ fn ensure_with_downloader(
.with_model_artifact_downloader(downloader as Arc<dyn ModelArtifactDownloader>)
}
fn delete_artifact_usecase(
registry: Arc<FakeRegistry>,
downloader: Arc<FakeModelArtifactDownloader>,
tracker: Arc<FakeDownloadTracker>,
profiles: Arc<FakeProfiles>,
) -> DeleteModelArtifact {
delete_artifact_usecase_with_live(
registry,
downloader,
tracker,
profiles,
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
Arc::new(FakeProjects::default()),
Arc::new(FakeContexts::default()),
Arc::new(FakeLive::default()),
)
}
fn delete_artifact_usecase_with_live(
registry: Arc<FakeRegistry>,
downloader: Arc<FakeModelArtifactDownloader>,
tracker: Arc<FakeDownloadTracker>,
profiles: Arc<FakeProfiles>,
probe: Arc<FakeProbe>,
projects: Arc<FakeProjects>,
contexts: Arc<FakeContexts>,
live: Arc<FakeLive>,
) -> DeleteModelArtifact {
DeleteModelArtifact::new(
registry as Arc<dyn ModelServerRegistry>,
probe as Arc<dyn ModelServerProbe>,
downloader as Arc<dyn ModelArtifactDownloader>,
tracker as Arc<dyn ModelArtifactDownloadTracker>,
profiles as Arc<dyn ProfileStore>,
projects as Arc<dyn ProjectStore>,
contexts as Arc<dyn AgentContextStore>,
live as Arc<dyn LiveAgentRegistry>,
)
}
fn progress(downloaded: Option<u64>, total: Option<u64>) -> ModelArtifactProgress {
ModelArtifactProgress {
downloaded_bytes: downloaded,
@ -1409,3 +1592,218 @@ async fn delete_model_server_removes_unused_config() {
assert!(registry.get(&sid(9)).await.unwrap().is_none());
}
#[tokio::test]
async fn delete_model_artifact_refuses_local_path_source() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(config(sid(25), 8105, "/models/qwen.gguf", false))
.await
.unwrap();
let downloader = Arc::new(FakeModelArtifactDownloader::new(
FakeDownloadOutcome::Resolve {
progress: Vec::new(),
path: "/cache/model.gguf",
cache_hit: true,
},
));
let usecase = delete_artifact_usecase(
Arc::clone(&registry),
Arc::clone(&downloader),
Arc::new(FakeDownloadTracker::default()),
Arc::new(FakeProfiles::default()),
);
let err = usecase
.execute(DeleteModelArtifactInput { server_id: sid(25) })
.await
.unwrap_err();
match err {
application::AppError::ModelServer { code, .. } => assert_eq!(code, "invalid"),
other => panic!("unexpected error: {other}"),
}
assert!(downloader.deleted.lock().unwrap().is_empty());
assert!(registry.get(&sid(25)).await.unwrap().is_some());
}
#[tokio::test]
async fn delete_model_artifact_refuses_download_in_progress() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(hf_config(sid(26), 8106, "Qwen/Qwen3-Coder:Q4_K_M"))
.await
.unwrap();
let tracker = Arc::new(FakeDownloadTracker::default());
tracker.in_progress.lock().unwrap().push(sid(26));
let downloader = Arc::new(FakeModelArtifactDownloader::new(
FakeDownloadOutcome::Resolve {
progress: Vec::new(),
path: "/cache/q4.gguf",
cache_hit: true,
},
));
let usecase = delete_artifact_usecase(
Arc::clone(&registry),
Arc::clone(&downloader),
tracker,
Arc::new(FakeProfiles::default()),
);
let err = usecase
.execute(DeleteModelArtifactInput { server_id: sid(26) })
.await
.unwrap_err();
match err {
application::AppError::ModelServer { code, .. } => {
assert_eq!(code, "model_server_in_use");
}
other => panic!("unexpected error: {other}"),
}
assert!(downloader.deleted.lock().unwrap().is_empty());
}
#[tokio::test]
async fn delete_model_artifact_refuses_live_agent_using_server_profile() {
let server_id = sid(27);
let project_id = pid(27);
let agent_id = aid(27);
let profile_id = ProfileId::from_uuid(uuid::Uuid::from_u128(270));
let registry = Arc::new(FakeRegistry::default());
registry
.save(hf_config(server_id, 8107, "Qwen/Qwen3-Coder:Q4_K_M"))
.await
.unwrap();
let profiles = Arc::new(FakeProfiles(Mutex::new(vec![opencode_profile(
profile_id.as_uuid().as_u128(),
server_id,
)])));
let contexts = Arc::new(FakeContexts {
manifest: AgentManifest::new(
1,
vec![ManifestEntry::new(
agent_id,
"Local Agent",
"agents/local.md",
profile_id,
None,
false,
None,
)
.unwrap()],
)
.unwrap(),
});
let live = Arc::new(FakeLive {
snapshots: vec![LiveSessionSnapshot {
project_id,
agent_id,
node_id: nid(27),
session_id: sess(27),
kind: LiveSessionKind::Pty,
}],
});
let downloader = Arc::new(FakeModelArtifactDownloader::new(
FakeDownloadOutcome::Resolve {
progress: Vec::new(),
path: "/cache/q4.gguf",
cache_hit: true,
},
));
let usecase = delete_artifact_usecase_with_live(
Arc::clone(&registry),
Arc::clone(&downloader),
Arc::new(FakeDownloadTracker::default()),
profiles,
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
Arc::new(FakeProjects { project_id }),
contexts,
live,
);
let err = usecase
.execute(DeleteModelArtifactInput { server_id })
.await
.unwrap_err();
match err {
application::AppError::ModelServer { code, .. } => {
assert_eq!(code, "model_server_in_use");
}
other => panic!("unexpected error: {other}"),
}
assert!(downloader.deleted.lock().unwrap().is_empty());
}
#[tokio::test]
async fn delete_model_artifact_refuses_reachable_server_endpoint() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(hf_config(sid(28), 8108, "Qwen/Qwen3-Coder:Q4_K_M"))
.await
.unwrap();
let downloader = Arc::new(FakeModelArtifactDownloader::new(
FakeDownloadOutcome::Resolve {
progress: Vec::new(),
path: "/cache/q4.gguf",
cache_hit: true,
},
));
let usecase = delete_artifact_usecase_with_live(
Arc::clone(&registry),
Arc::clone(&downloader),
Arc::new(FakeDownloadTracker::default()),
Arc::new(FakeProfiles::default()),
Arc::new(FakeProbe::new(vec![ModelServerStatus::ReadyReused])),
Arc::new(FakeProjects::default()),
Arc::new(FakeContexts::default()),
Arc::new(FakeLive::default()),
);
let err = usecase
.execute(DeleteModelArtifactInput { server_id: sid(28) })
.await
.unwrap_err();
match err {
application::AppError::ModelServer { code, .. } => {
assert_eq!(code, "model_server_in_use");
}
other => panic!("unexpected error: {other}"),
}
assert!(downloader.deleted.lock().unwrap().is_empty());
}
#[tokio::test]
async fn delete_model_artifact_deletes_hf_cache_without_deleting_config() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(hf_config(sid(27), 8107, "Qwen/Qwen3-Coder:Q4_K_M"))
.await
.unwrap();
let downloader = Arc::new(FakeModelArtifactDownloader::new(
FakeDownloadOutcome::Resolve {
progress: Vec::new(),
path: "/cache/q4.gguf",
cache_hit: true,
},
));
let usecase = delete_artifact_usecase(
Arc::clone(&registry),
Arc::clone(&downloader),
Arc::new(FakeDownloadTracker::default()),
Arc::new(FakeProfiles::default()),
);
usecase
.execute(DeleteModelArtifactInput { server_id: sid(27) })
.await
.unwrap();
assert_eq!(
downloader.deleted.lock().unwrap().as_slice(),
["Qwen/Qwen3-Coder:Q4_K_M"]
);
assert!(registry.get(&sid(27)).await.unwrap().is_some());
}

View File

@ -24,11 +24,12 @@ use domain::profile::{
use domain::project::ProjectPath;
use application::{
reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed,
CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles,
ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveProfile,
SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
claude_model_catalogue, codex_model_catalogue, reference_profile_id, reference_profiles,
AppError, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
CloneProfileFromSeed, CloneProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput,
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState,
ListProfiles, ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
};
// ---------------------------------------------------------------------------
@ -869,6 +870,114 @@ async fn clone_opencode_profile_falls_back_to_catalogue_when_persisted_seed_is_n
assert_eq!(out.profile.name, "OpenCode + llama.cpp copy");
}
#[tokio::test]
async fn clone_profile_from_seed_creates_codex_profile_with_fresh_id_and_model_override() {
let store = FakeProfileStore::default();
let clone = CloneProfileFromSeed::new(
Arc::new(store.clone()),
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3801)])),
);
let out = clone
.execute(CloneProfileFromSeedInput {
seed_profile_id: reference_profile_id("codex"),
name: Some("Codex GPT-5".to_owned()),
model: Some("gpt-5-codex".to_owned()),
})
.await
.unwrap();
assert_eq!(
out.profile.id,
ProfileId::from_uuid(uuid::Uuid::from_u128(3801))
);
assert_eq!(out.profile.name, "Codex GPT-5");
assert_eq!(out.profile.model.as_deref(), Some("gpt-5-codex"));
assert_eq!(
out.profile.structured_adapter,
Some(StructuredAdapter::Codex)
);
assert_eq!(store.0.lock().unwrap().profiles, vec![out.profile]);
}
#[tokio::test]
async fn clone_profile_from_seed_prefers_persisted_seed_and_preserves_model_by_default() {
let store = FakeProfileStore::default();
let persisted = reference_profiles()
.into_iter()
.find(|profile| profile.id == reference_profile_id("claude"))
.expect("seed exists")
.with_model("claude-opus-4-8");
SaveProfile::new(Arc::new(store.clone()))
.execute(SaveProfileInput {
profile: persisted.clone(),
})
.await
.unwrap();
let clone = CloneProfileFromSeed::new(
Arc::new(store.clone()),
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3802)])),
);
let out = clone
.execute(CloneProfileFromSeedInput {
seed_profile_id: persisted.id,
name: None,
model: None,
})
.await
.unwrap();
assert_eq!(out.profile.name, "Claude Code copy");
assert_eq!(out.profile.model.as_deref(), Some("claude-opus-4-8"));
assert_eq!(
out.profile.structured_adapter,
Some(StructuredAdapter::Claude)
);
assert_ne!(out.profile.id, persisted.id);
assert_eq!(store.0.lock().unwrap().profiles.len(), 2);
}
#[tokio::test]
async fn clone_profile_from_seed_rejects_blank_model_override() {
let store = FakeProfileStore::default();
let clone = CloneProfileFromSeed::new(
Arc::new(store),
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3803)])),
);
let err = clone
.execute(CloneProfileFromSeedInput {
seed_profile_id: reference_profile_id("claude"),
name: Some("Claude blank".to_owned()),
model: Some(" ".to_owned()),
})
.await
.unwrap_err();
assert!(matches!(err, AppError::Invalid(_)));
}
#[tokio::test]
async fn clone_profile_from_seed_rejects_blank_name_override() {
let store = FakeProfileStore::default();
let clone = CloneProfileFromSeed::new(
Arc::new(store),
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3804)])),
);
let err = clone
.execute(CloneProfileFromSeedInput {
seed_profile_id: reference_profile_id("codex"),
name: Some(" ".to_owned()),
model: Some("gpt-5-codex".to_owned()),
})
.await
.unwrap_err();
assert!(matches!(err, AppError::Invalid(_)));
}
// ---------------------------------------------------------------------------
// ReferenceProfiles / catalogue
// ---------------------------------------------------------------------------
@ -1049,3 +1158,22 @@ fn catalogue_gemini_and_aider_stay_pty_without_adapter() {
assert_eq!(by_command["gemini"].structured_adapter, None);
assert_eq!(by_command["aider"].structured_adapter, None);
}
#[test]
fn claude_and_codex_model_catalogues_are_static_and_searchable() {
let claude = claude_model_catalogue();
let codex = codex_model_catalogue();
assert!(claude
.iter()
.any(|model| model.model_id == "claude-sonnet-5" && model.recommended));
assert!(codex
.iter()
.any(|model| model.model_id == "gpt-5-codex" && model.recommended));
assert!(claude
.iter()
.all(|model| model.adapter == StructuredAdapter::Claude && !model.display_name.is_empty()));
assert!(codex
.iter()
.all(|model| model.adapter == StructuredAdapter::Codex && !model.display_name.is_empty()));
}

View File

@ -1047,6 +1047,78 @@ impl From<CloneOpenCodeProfileFromSeedOutput> for ProfileDto {
}
}
impl From<application::CloneProfileFromSeedOutput> for ProfileDto {
fn from(out: application::CloneProfileFromSeedOutput) -> Self {
Self(out.profile)
}
}
/// One entry of a curated structured-profile model catalogue.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileModelCatalogEntryDto {
/// Structured adapter this model belongs to.
pub adapter: domain::profile::StructuredAdapter,
/// Exact model identifier to persist on `AgentProfile.model`.
pub model_id: String,
/// Human-readable label for picker display.
pub display_name: String,
/// Extra search tokens useful to the frontend.
pub aliases: Vec<String>,
/// Whether this entry is the conservative default suggestion.
pub recommended: bool,
/// Compatibility state against the locally detected CLI version.
pub compatibility: domain::ModelCompatibility,
/// Source that contributed the model entry.
pub source: domain::ModelCatalogSource,
}
impl From<application::ProfileModelCatalogEntry> for ProfileModelCatalogEntryDto {
fn from(entry: application::ProfileModelCatalogEntry) -> Self {
Self {
adapter: entry.adapter,
model_id: entry.model_id,
display_name: entry.display_name,
aliases: entry.aliases,
recommended: entry.recommended,
compatibility: entry.compatibility,
source: entry.source,
}
}
}
/// Enriched structured-profile model catalogue.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileModelCatalogDto {
/// The catalogue entries.
pub models: Vec<ProfileModelCatalogEntryDto>,
/// Best-effort local CLI version.
pub cli_version: Option<String>,
/// Non-fatal fallback/degradation warnings.
pub warnings: Vec<String>,
}
impl From<application::ListClaudeModelsOutput> for ProfileModelCatalogDto {
fn from(out: application::ListClaudeModelsOutput) -> Self {
Self {
models: out.models.into_iter().map(Into::into).collect(),
cli_version: out.cli_version.map(|version| version.raw),
warnings: out.warnings,
}
}
}
impl From<application::ListCodexModelsOutput> for ProfileModelCatalogDto {
fn from(out: application::ListCodexModelsOutput) -> Self {
Self {
models: out.models.into_iter().map(Into::into).collect(),
cli_version: out.cli_version.map(|version| version.raw),
warnings: out.warnings,
}
}
}
/// One entry of the static OpenCode cloud-provider catalogue (ticket #92, lot B3).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@ -1202,6 +1274,30 @@ impl From<CloneOpenCodeProfileFromSeedRequestDto> for CloneOpenCodeProfileFromSe
}
}
/// Request DTO for `clone_profile_from_seed`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CloneProfileFromSeedRequestDto {
/// Id of the persisted or reference profile to clone.
pub seed_profile_id: domain::ids::ProfileId,
/// Optional display name for the new profile.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Optional model override. When omitted, the seed model is copied.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
impl From<CloneProfileFromSeedRequestDto> for application::CloneProfileFromSeedInput {
fn from(dto: CloneProfileFromSeedRequestDto) -> Self {
Self {
seed_profile_id: dto.seed_profile_id,
name: dto.name,
model: dto.model,
}
}
}
/// Request DTO for `configure_profiles` (closes the first run).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -1247,7 +1343,10 @@ impl From<FirstRunStateOutput> for FirstRunStateDto {
// Local model servers (B35)
// ---------------------------------------------------------------------------
use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput};
use application::{
ListModelServersOutput, ModelArtifactView, ModelServerListItem, SaveModelServerInput,
SaveModelServerOutput,
};
use domain::model_server::{
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource,
@ -1396,6 +1495,9 @@ pub struct ModelServerConfigDto {
/// Optional readiness warmup deadline override in seconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warmup_deadline_secs: Option<u64>,
/// Derived local artifact cache state.
#[serde(default)]
pub artifact: ModelArtifactDto,
}
impl ModelServerConfigDto {
@ -1420,6 +1522,7 @@ impl ModelServerConfigDto {
auto_start: config.auto_start,
stop_policy: config.stop_policy.into(),
warmup_deadline_secs: config.warmup_deadline_secs,
artifact: ModelArtifactDto::NotManaged,
}
}
@ -1467,6 +1570,53 @@ impl ModelServerConfigDto {
}
}
/// Local model artifact cache state on the IPC wire.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "state")]
pub enum ModelArtifactDto {
/// The configured source is not managed by IdeA's downloader.
NotManaged,
/// The configured source is managed but not present in cache.
Missing,
/// A download/prepare operation is currently running for this server.
Downloading,
/// The configured source is present in cache.
Downloaded {
/// Local artifact path.
path: String,
/// Total on-disk size when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
size_bytes: Option<u64>,
},
}
impl Default for ModelArtifactDto {
fn default() -> Self {
Self::NotManaged
}
}
impl From<ModelArtifactView> for ModelArtifactDto {
fn from(view: ModelArtifactView) -> Self {
match view {
ModelArtifactView::NotManaged => Self::NotManaged,
ModelArtifactView::Missing => Self::Missing,
ModelArtifactView::Downloading => Self::Downloading,
ModelArtifactView::Downloaded { path, size_bytes } => {
Self::Downloaded { path, size_bytes }
}
}
}
}
impl From<ModelServerListItem> for ModelServerConfigDto {
fn from(item: ModelServerListItem) -> Self {
let mut dto = Self::from_domain(item.config);
dto.artifact = item.artifact.into();
dto
}
}
/// Response DTO for `preview_model_server_command`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
@ -1489,7 +1639,7 @@ impl From<ListModelServersOutput> for ModelServerConfigListDto {
Self(
out.servers
.into_iter()
.map(ModelServerConfigDto::from_domain)
.map(ModelServerConfigDto::from)
.collect(),
)
}
@ -2461,6 +2611,15 @@ pub struct AgentBackgroundTaskStateDto {
/// Bounded stderr tail.
#[serde(skip_serializing_if = "Option::is_none")]
pub stderr_tail: Option<String>,
/// Agent that requested a headless rendezvous, when known.
#[serde(skip_serializing_if = "Option::is_none")]
pub requester_agent_id: Option<String>,
/// Target agent for a headless rendezvous.
#[serde(skip_serializing_if = "Option::is_none")]
pub target_agent_id: Option<String>,
/// Conversation opened by a headless rendezvous.
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
/// Creation timestamp, epoch milliseconds.
pub created_at_ms: u64,
/// Last update timestamp, epoch milliseconds.
@ -2477,6 +2636,9 @@ impl From<AgentBackgroundTaskState> for AgentBackgroundTaskStateDto {
summary: task.summary,
stdout_tail: task.stdout_tail,
stderr_tail: task.stderr_tail,
requester_agent_id: task.requester_agent_id.map(|id| id.to_string()),
target_agent_id: task.target_agent_id.map(|id| id.to_string()),
conversation_id: task.conversation_id.map(|id| id.to_string()),
created_at_ms: task.created_at_ms,
updated_at_ms: task.updated_at_ms,
}
@ -3902,6 +4064,15 @@ pub struct BackgroundTaskDto {
/// Bounded stderr tail (unset for PTY-backed commands, which merge streams).
#[serde(skip_serializing_if = "Option::is_none")]
pub stderr_tail: Option<String>,
/// Agent that requested a headless rendezvous, when known.
#[serde(skip_serializing_if = "Option::is_none")]
pub requester_agent_id: Option<String>,
/// Target agent for a headless rendezvous.
#[serde(skip_serializing_if = "Option::is_none")]
pub target_agent_id: Option<String>,
/// Conversation opened by a headless rendezvous.
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
/// Creation timestamp, epoch milliseconds.
pub created_at_ms: u64,
/// Last update timestamp, epoch milliseconds.
@ -3943,6 +4114,8 @@ fn background_state_label(state: BackgroundTaskState) -> &'static str {
impl From<BackgroundTask> for BackgroundTaskDto {
fn from(task: BackgroundTask) -> Self {
let (requester_agent_id, target_agent_id, conversation_id) =
background_rendezvous_context_labels(&task.kind);
let (exit_code, summary, stdout_tail, stderr_tail) = match &task.result {
Some(BackgroundTaskResult::Success {
exit_code,
@ -3984,12 +4157,33 @@ impl From<BackgroundTask> for BackgroundTaskDto {
summary,
stdout_tail,
stderr_tail,
requester_agent_id,
target_agent_id,
conversation_id,
created_at_ms: task.created_at_ms,
updated_at_ms: task.updated_at_ms,
}
}
}
fn background_rendezvous_context_labels(
kind: &BackgroundTaskKind,
) -> (Option<String>, Option<String>, Option<String>) {
match kind {
BackgroundTaskKind::HeadlessRendezvous {
requester_agent_id,
target_agent_id,
conversation_id,
..
} => (
requester_agent_id.map(|id| id.to_string()),
Some(target_agent_id.to_string()),
Some(conversation_id.to_string()),
),
_ => (None, None, None),
}
}
/// Parses a task-id string (UUID) coming from the frontend.
///
/// # Errors
@ -4038,7 +4232,8 @@ pub struct SpawnBackgroundCommandRequestDto {
#[cfg(test)]
mod tests {
use application::McpToolPermissionCatalogue;
use domain::{AgentId, ProjectMcpToolPermissions};
use domain::mailbox::TicketId;
use domain::{AgentId, ConversationId, ProjectMcpToolPermissions};
use serde_json::json;
use uuid::Uuid;
@ -4089,4 +4284,78 @@ mod tests {
})
);
}
#[test]
fn background_task_dto_exposes_rendezvous_context_only_for_headless_rendezvous() {
let project_id = ProjectId::from_uuid(Uuid::from_u128(1));
let owner = AgentId::from_uuid(Uuid::from_u128(2));
let requester = AgentId::from_uuid(Uuid::from_u128(3));
let conversation_id = ConversationId::from_uuid(Uuid::from_u128(4));
let task = BackgroundTask::new(
TaskId::from_uuid(Uuid::from_u128(5)),
project_id,
owner,
BackgroundTaskKind::HeadlessRendezvous {
requester_agent_id: Some(requester),
target_agent_id: owner,
ticket_id: TicketId::from_uuid(Uuid::from_u128(6)),
conversation_id,
},
domain::BackgroundTaskWakePolicy::RecordOnly,
100,
None,
)
.unwrap();
let json = serde_json::to_value(BackgroundTaskDto::from(task)).unwrap();
assert_eq!(json["kind"], "headlessRendezvous");
assert_eq!(json["requesterAgentId"], requester.to_string());
assert_eq!(json["targetAgentId"], owner.to_string());
assert_eq!(json["conversationId"], conversation_id.to_string());
let command = BackgroundTask::new(
TaskId::from_uuid(Uuid::from_u128(7)),
project_id,
owner,
BackgroundTaskKind::Command {
label: "cargo test".to_owned(),
},
domain::BackgroundTaskWakePolicy::RecordOnly,
100,
None,
)
.unwrap();
let json = serde_json::to_value(BackgroundTaskDto::from(command)).unwrap();
assert!(json.get("requesterAgentId").is_none());
assert!(json.get("targetAgentId").is_none());
assert!(json.get("conversationId").is_none());
}
#[test]
fn agent_background_task_state_dto_exposes_rendezvous_context() {
let requester = AgentId::from_uuid(Uuid::from_u128(11));
let target = AgentId::from_uuid(Uuid::from_u128(12));
let conversation_id = ConversationId::from_uuid(Uuid::from_u128(13));
let state = AgentBackgroundTaskState {
task_id: TaskId::from_uuid(Uuid::from_u128(14)),
kind: BackgroundTaskKindLabel::HeadlessRendezvous,
state: BackgroundTaskState::Completed,
exit_code: None,
summary: Some("ok".to_owned()),
stdout_tail: None,
stderr_tail: None,
requester_agent_id: Some(requester),
target_agent_id: Some(target),
conversation_id: Some(conversation_id),
created_at_ms: 100,
updated_at_ms: 200,
};
let json = serde_json::to_value(AgentBackgroundTaskStateDto::from(state)).unwrap();
assert_eq!(json["requesterAgentId"], requester.to_string());
assert_eq!(json["targetAgentId"], target.to_string());
assert_eq!(json["conversationId"], conversation_id.to_string());
}
}

View File

@ -6,7 +6,7 @@
use serde::Serialize;
use domain::conversation::ConversationParty;
use domain::events::{DomainEvent, OrchestrationSource};
use domain::events::{DomainEvent, OrchestrationSource, RendezvousContext};
use domain::input::AgentLiveness;
use domain::model_server::ModelServerLifecycleStatus;
use domain::{IssueLinkKind, IssuePriority, IssueStatus};
@ -298,6 +298,15 @@ pub enum DomainEventDto {
agent_id: String,
/// Lightweight event/state label.
state: String,
/// Agent that requested a headless rendezvous, when known.
#[serde(skip_serializing_if = "Option::is_none")]
requester_agent_id: Option<String>,
/// Target agent for a headless rendezvous.
#[serde(skip_serializing_if = "Option::is_none")]
target_agent_id: Option<String>,
/// Conversation opened by a headless rendezvous.
#[serde(skip_serializing_if = "Option::is_none")]
conversation_id: Option<String>,
},
/// An agent inbox queue depth changed.
#[serde(rename_all = "camelCase")]
@ -725,6 +734,24 @@ fn conversation_party_wire(party: ConversationParty) -> String {
}
}
fn rendezvous_requester_agent_id(rendezvous: &Option<RendezvousContext>) -> Option<String> {
rendezvous
.as_ref()
.and_then(|ctx| ctx.requester_agent_id.map(|id| id.to_string()))
}
fn rendezvous_target_agent_id(rendezvous: &Option<RendezvousContext>) -> Option<String> {
rendezvous
.as_ref()
.map(|ctx| ctx.target_agent_id.to_string())
}
fn rendezvous_conversation_id(rendezvous: &Option<RendezvousContext>) -> Option<String> {
rendezvous
.as_ref()
.map(|ctx| ctx.conversation_id.to_string())
}
impl From<&DomainEvent> for DomainEventDto {
fn from(e: &DomainEvent) -> Self {
match e {
@ -862,6 +889,9 @@ impl From<&DomainEvent> for DomainEventDto {
task_id: task_id.to_string(),
agent_id: owner_agent_id.to_string(),
state: "started".to_owned(),
requester_agent_id: None,
target_agent_id: None,
conversation_id: None,
},
DomainEvent::BackgroundTaskStateChanged {
project_id,
@ -873,36 +903,51 @@ impl From<&DomainEvent> for DomainEventDto {
task_id: task_id.to_string(),
agent_id: owner_agent_id.to_string(),
state: format!("{state:?}"),
requester_agent_id: None,
target_agent_id: None,
conversation_id: None,
},
DomainEvent::BackgroundTaskCompleted {
project_id,
task_id,
owner_agent_id,
rendezvous,
} => Self::BackgroundTaskChanged {
project_id: project_id.to_string(),
task_id: task_id.to_string(),
agent_id: owner_agent_id.to_string(),
state: "completed".to_owned(),
requester_agent_id: rendezvous_requester_agent_id(rendezvous),
target_agent_id: rendezvous_target_agent_id(rendezvous),
conversation_id: rendezvous_conversation_id(rendezvous),
},
DomainEvent::BackgroundTaskFailed {
project_id,
task_id,
owner_agent_id,
rendezvous,
} => Self::BackgroundTaskChanged {
project_id: project_id.to_string(),
task_id: task_id.to_string(),
agent_id: owner_agent_id.to_string(),
state: "failed".to_owned(),
requester_agent_id: rendezvous_requester_agent_id(rendezvous),
target_agent_id: rendezvous_target_agent_id(rendezvous),
conversation_id: rendezvous_conversation_id(rendezvous),
},
DomainEvent::BackgroundTaskCancelled {
project_id,
task_id,
owner_agent_id,
rendezvous,
} => Self::BackgroundTaskChanged {
project_id: project_id.to_string(),
task_id: task_id.to_string(),
agent_id: owner_agent_id.to_string(),
state: "cancelled".to_owned(),
requester_agent_id: rendezvous_requester_agent_id(rendezvous),
target_agent_id: rendezvous_target_agent_id(rendezvous),
conversation_id: rendezvous_conversation_id(rendezvous),
},
DomainEvent::BackgroundTaskCompletionDeliveryPending {
project_id,
@ -913,6 +958,9 @@ impl From<&DomainEvent> for DomainEventDto {
task_id: task_id.to_string(),
agent_id: owner_agent_id.to_string(),
state: "deliveryPending".to_owned(),
requester_agent_id: None,
target_agent_id: None,
conversation_id: None,
},
DomainEvent::BackgroundTaskCompletionDelivered {
project_id,
@ -923,6 +971,9 @@ impl From<&DomainEvent> for DomainEventDto {
task_id: task_id.to_string(),
agent_id: owner_agent_id.to_string(),
state: "delivered".to_owned(),
requester_agent_id: None,
target_agent_id: None,
conversation_id: None,
},
DomainEvent::AgentInboxQueued { agent_id, depth } => Self::AgentInboxChanged {
agent_id: agent_id.to_string(),
@ -1205,7 +1256,7 @@ mod tests {
use super::*;
use domain::ids::AgentId;
use domain::mailbox::TicketId;
use domain::{LocalModelServerId, ProjectId};
use domain::{ConversationId, LocalModelServerId, ProjectId, TaskId};
use serde_json::json;
fn agent(n: u128) -> AgentId {
@ -1216,6 +1267,14 @@ mod tests {
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
}
fn task(n: u128) -> TaskId {
TaskId::from_uuid(uuid::Uuid::from_u128(n))
}
fn conversation(n: u128) -> ConversationId {
ConversationId::from_uuid(uuid::Uuid::from_u128(n))
}
#[test]
fn model_server_status_changed_relays_ready_to_dto_and_wire() {
let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged {
@ -1348,6 +1407,56 @@ mod tests {
);
}
#[test]
fn background_completion_relays_rendezvous_context_to_wire() {
let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1));
let task_id = task(2);
let requester = agent(3);
let target = agent(4);
let conversation_id = conversation(5);
let dto = DomainEventDto::from(&DomainEvent::BackgroundTaskCompleted {
project_id,
task_id,
owner_agent_id: target,
rendezvous: Some(RendezvousContext {
requester_agent_id: Some(requester),
target_agent_id: target,
conversation_id,
}),
});
assert_eq!(
serde_json::to_value(&dto).unwrap(),
json!({
"type": "backgroundTaskChanged",
"projectId": project_id.to_string(),
"taskId": task_id.to_string(),
"agentId": target.to_string(),
"state": "completed",
"requesterAgentId": requester.to_string(),
"targetAgentId": target.to_string(),
"conversationId": conversation_id.to_string(),
})
);
}
#[test]
fn background_failure_without_rendezvous_omits_context_fields() {
let dto = DomainEventDto::from(&DomainEvent::BackgroundTaskFailed {
project_id: ProjectId::from_uuid(uuid::Uuid::from_u128(1)),
task_id: task(2),
owner_agent_id: agent(3),
rendezvous: None,
});
let json = serde_json::to_value(&dto).unwrap();
assert_eq!(json["type"], "backgroundTaskChanged");
assert!(json.get("requesterAgentId").is_none());
assert!(json.get("targetAgentId").is_none());
assert!(json.get("conversationId").is_none());
}
/// LS6 : un `AgentRateLimited` du domaine se relaie en DTO portant le même agent
/// et l'heure de reset (époche-ms), et se sérialise en `"agentRateLimited"` avec
/// `resetsAtMs` — le fait neutre que le front badge « limité jusqu'à HH:MM ».

View File

@ -15,24 +15,24 @@ use application::{
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive,
CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
CloneOpenCodeProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant,
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint,
CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory,
DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate,
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory,
GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches,
GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
HarvestMemoryFromTurn, HealthUseCase, InspectConversation, InstallPluginFromArchive,
InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput,
LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues,
ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders,
ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents,
ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue,
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal,
CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill,
CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout,
DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState,
GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation,
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent,
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
@ -59,8 +59,8 @@ use domain::ports::{
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore,
PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort,
ModelArtifactDownloader, PermissionStore, PluginManifestValidator, PluginMcpSupervisor,
PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort,
RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
WakeError, WakeReason, WindowStateStore,
@ -80,19 +80,20 @@ use uuid::Uuid;
use infrastructure::{
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog,
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore,
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore,
FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore,
FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore,
FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer,
HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore,
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox,
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, ReadOnlyRuntimePermissionProbe,
RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider,
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner,
EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor,
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore,
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore,
FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess,
LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle,
PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard,
StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider,
TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
@ -940,6 +941,12 @@ pub struct BackendCore {
pub save_opencode_provider_profile: Arc<SaveOpenCodeProviderProfile>,
/// Static catalogue of OpenCode cloud providers (ticket #92, lot B3).
pub list_opencode_providers: Arc<ListOpenCodeProviders>,
/// Static curated Claude model catalogue.
pub list_claude_models: Arc<ListClaudeModels>,
/// Static curated Codex model catalogue.
pub list_codex_models: Arc<ListCodexModels>,
/// Create a new profile instance from a persisted/reference seed.
pub clone_profile_from_seed: Arc<CloneProfileFromSeed>,
/// Create a new OpenCode profile instance from the canonical seed.
pub clone_opencode_profile_from_seed: Arc<CloneOpenCodeProfileFromSeed>,
/// Delete a profile.
@ -958,6 +965,8 @@ pub struct BackendCore {
pub save_model_server: Arc<SaveModelServer>,
/// Deletes local model server configurations when unused.
pub delete_model_server: Arc<DeleteModelServer>,
/// Deletes managed local model artifacts without deleting server configs.
pub delete_model_artifact: Arc<DeleteModelArtifact>,
/// The local PTY adapter, kept port-typed so driving adapters can subscribe
/// output and route it through their own transport bridge.
pub pty_port: Arc<dyn PtyPort>,
@ -1468,6 +1477,25 @@ impl BackendCore {
Arc::clone(&ids) as Arc<dyn IdGenerator>,
));
let list_opencode_providers = Arc::new(ListOpenCodeProviders::new());
let cli_version_reader = Arc::new(ProcessCliVersionReader::new(Arc::clone(&spawner_port)));
let provider_model_catalogue = Arc::new(HttpProviderModelCatalogue::new());
let compatibility_matrix = Arc::new(EmbeddedCompatibilityMatrix::with_app_data_dir(
app_data_dir.clone(),
));
let list_claude_models = Arc::new(ListClaudeModels::new(
Arc::clone(&cli_version_reader) as Arc<dyn domain::ports::CliVersionReader>,
Arc::clone(&provider_model_catalogue) as Arc<dyn domain::ports::ProviderModelCatalogue>,
Arc::clone(&compatibility_matrix) as Arc<dyn domain::ports::CompatibilityMatrixSource>,
));
let list_codex_models = Arc::new(ListCodexModels::new(
cli_version_reader as Arc<dyn domain::ports::CliVersionReader>,
provider_model_catalogue as Arc<dyn domain::ports::ProviderModelCatalogue>,
compatibility_matrix as Arc<dyn domain::ports::CompatibilityMatrixSource>,
));
let clone_profile_from_seed = Arc::new(CloneProfileFromSeed::new(
Arc::clone(&profile_store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
));
let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new(
Arc::clone(&profile_store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
@ -1487,25 +1515,31 @@ impl BackendCore {
let model_artifact_downloader = Arc::new(HfModelArtifactDownloader::new(
app_data_dir.join("hf-model-artifacts"),
));
let model_server_probe_port = Arc::new(HttpOpenAiCompatibleProbe::default())
as Arc<dyn domain::ports::ModelServerProbe>;
let ensure_local_model_server = Arc::new(
EnsureLocalModelServer::new(
Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>,
Arc::new(HttpOpenAiCompatibleProbe::default())
as Arc<dyn domain::ports::ModelServerProbe>,
Arc::clone(&model_server_probe_port),
Arc::new(LocalManagedProcess::new()) as Arc<dyn domain::ports::ManagedProcess>,
Arc::new(LlamaCppRuntime::new()) as Arc<dyn domain::ports::ModelServerRuntime>,
Arc::clone(&fs_port),
Arc::clone(&events_port),
)
.with_model_artifact_downloader(
model_artifact_downloader as Arc<dyn domain::ports::ModelArtifactDownloader>,
),
.with_model_artifact_downloader(Arc::clone(&model_artifact_downloader)
as Arc<dyn domain::ports::ModelArtifactDownloader>),
);
let model_server_registry_port =
Arc::clone(&model_server_registry) as Arc<dyn domain::ports::ModelServerRegistry>;
let list_model_servers = Arc::new(ListModelServers::new(Arc::clone(
&model_server_registry_port,
)));
let model_artifact_downloader_port =
Arc::clone(&model_artifact_downloader) as Arc<dyn ModelArtifactDownloader>;
let model_artifact_download_tracker = Arc::clone(&ensure_local_model_server)
as Arc<dyn application::ModelArtifactDownloadTracker>;
let list_model_servers = Arc::new(
ListModelServers::new(Arc::clone(&model_server_registry_port))
.with_model_artifact_downloader(Arc::clone(&model_artifact_downloader_port))
.with_download_tracker(Arc::clone(&model_artifact_download_tracker)),
);
let save_model_server = Arc::new(SaveModelServer::new(Arc::clone(
&model_server_registry_port,
)));
@ -2361,6 +2395,16 @@ impl BackendCore {
Arc::clone(&terminal_sessions),
Arc::clone(&structured_sessions),
));
let delete_model_artifact = Arc::new(DeleteModelArtifact::new(
Arc::clone(&model_server_registry_port),
Arc::clone(&model_server_probe_port),
Arc::clone(&model_artifact_downloader_port),
Arc::clone(&model_artifact_download_tracker),
Arc::clone(&profile_store_port),
Arc::clone(&store_port),
Arc::clone(&contexts_port),
Arc::clone(&live_sessions) as Arc<dyn LiveAgentRegistry>,
));
// Réconciliation du live-state au reboot : repasse en `idle` les lignes
// fantômes (working/waiting/blocked) dont la session n'est plus vivante,
// selon le MÊME registre de liveness que `GetProjectWorkState`. Provider
@ -2661,6 +2705,9 @@ impl BackendCore {
save_profile,
save_opencode_provider_profile,
list_opencode_providers,
list_claude_models,
list_codex_models,
clone_profile_from_seed,
clone_opencode_profile_from_seed,
delete_profile,
configure_profiles,
@ -2670,6 +2717,7 @@ impl BackendCore {
list_model_servers,
save_model_server,
delete_model_server,
delete_model_artifact,
pty_port,
terminal_sessions,
event_bus,

View File

@ -1,7 +1,7 @@
//! Domain events published on the [`crate::ports::EventBus`] and relayed to the
//! presentation layer (ARCHITECTURE §3.2).
use crate::conversation::ConversationParty;
use crate::conversation::{ConversationId, ConversationParty};
use crate::device::DeviceId;
use crate::ids::{
AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId,
@ -14,6 +14,18 @@ use crate::plugin::{PluginId, PluginMcpServerId, PluginVersion};
use crate::sprint::{SprintOrder, SprintVersion};
use crate::template::TemplateVersion;
/// Context carried by terminal background-task events when they come from a
/// headless inter-agent rendezvous.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RendezvousContext {
/// Agent that requested the rendezvous, when known.
pub requester_agent_id: Option<AgentId>,
/// Target agent that owns the rendezvous conversation.
pub target_agent_id: AgentId,
/// Conversation opened by the target turn.
pub conversation_id: ConversationId,
}
/// Which entry door a processed orchestration request arrived through.
///
/// IdeA exposes the *same* [`crate::OrchestratorService::dispatch`] behind two
@ -142,6 +154,8 @@ pub enum DomainEvent {
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
/// Rendezvous context when this terminal task is a headless ask.
rendezvous: Option<RendezvousContext>,
},
/// A first-class background task failed.
BackgroundTaskFailed {
@ -151,6 +165,8 @@ pub enum DomainEvent {
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
/// Rendezvous context when this terminal task is a headless ask.
rendezvous: Option<RendezvousContext>,
},
/// A first-class background task was cancelled.
BackgroundTaskCancelled {
@ -160,6 +176,8 @@ pub enum DomainEvent {
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
/// Rendezvous context when this terminal task is a headless ask.
rendezvous: Option<RendezvousContext>,
},
/// A first-class background task has a terminal result not yet delivered.
BackgroundTaskCompletionDeliveryPending {

View File

@ -51,6 +51,7 @@ pub mod markdown;
pub mod mcp_tool_permissions;
pub mod memory;
pub mod memory_harvest;
pub mod model_catalogue;
pub mod model_server;
pub mod orchestrator;
pub mod permission;
@ -75,6 +76,7 @@ mod validation;
// ---------------------------------------------------------------------------
pub use error::DomainError;
pub use events::RendezvousContext;
pub use ids::{
AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RuntimeAgentKey,
@ -167,6 +169,11 @@ pub use memory_harvest::{
MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS,
};
pub use model_catalogue::{
evaluate_compatibility, CliVersion, CompatibilityMatrix, ModelCatalogSource,
ModelCatalogueError, ModelCompatibility,
};
pub use model_server::{
validate_free_args, ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef,
LocalModelServerConfig, LocalModelServerKind, ModelPath, ModelServerEndpoint,
@ -226,16 +233,17 @@ pub use ports::{
AgentContextStore, AgentRuntime, AgentToolPolicyStore, AssistantContextError,
AssistantContextProvider, BackgroundCompletionStream, BackgroundTaskCompletion,
BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec,
BackgroundTaskStore, Clock, ContextInjectionPlan, DirEntry, Embedder, EmbedderEnvInspector,
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo,
GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore,
IssueStoreError, LiveStateStore, LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery,
MemoryRecall, MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError,
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
BackgroundTaskStore, CliVersionReader, Clock, CompatibilityMatrixSource, ContextInjectionPlan,
DirEntry, Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError,
EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream,
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
IdGenerator, IssueNumberAllocator, IssueStore, IssueStoreError, LiveStateStore, LocalPath,
McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore,
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
Output, OutputStream, PermissionStore, PluginManifestBytes, PluginManifestError,
PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
PluginRegistryError, PluginRegistryStore, PluginStoreError, PreparedContext, ProcessError,
ProcessSpawner, ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle,
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe,
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore,

View File

@ -0,0 +1,216 @@
//! Pure model-catalogue compatibility types.
use core::cmp::Ordering;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::profile::StructuredAdapter;
/// Parsed CLI version used for local compatibility checks.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CliVersion {
/// Original version string reported by the CLI.
pub raw: String,
parts: Vec<u64>,
}
impl CliVersion {
/// Parses a version from a string containing at least one digit.
///
/// # Errors
/// Returns [`ModelCatalogueError::InvalidVersion`] when no numeric version
/// segment can be found.
pub fn parse(raw: impl Into<String>) -> Result<Self, ModelCatalogueError> {
let raw = raw.into();
let start = raw
.char_indices()
.find_map(|(idx, ch)| ch.is_ascii_digit().then_some(idx))
.ok_or_else(|| ModelCatalogueError::InvalidVersion(raw.clone()))?;
let version = raw[start..]
.chars()
.take_while(|ch| ch.is_ascii_digit() || *ch == '.')
.collect::<String>();
let parts = version
.split('.')
.filter(|part| !part.is_empty())
.map(str::parse::<u64>)
.collect::<Result<Vec<_>, _>>()
.map_err(|_| ModelCatalogueError::InvalidVersion(raw.clone()))?;
if parts.is_empty() {
return Err(ModelCatalogueError::InvalidVersion(raw));
}
Ok(Self { raw, parts })
}
}
impl PartialOrd for CliVersion {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for CliVersion {
fn cmp(&self, other: &Self) -> Ordering {
let max_len = self.parts.len().max(other.parts.len());
for idx in 0..max_len {
match self
.parts
.get(idx)
.copied()
.unwrap_or(0)
.cmp(&other.parts.get(idx).copied().unwrap_or(0))
{
Ordering::Equal => {}
ordering => return ordering,
}
}
Ordering::Equal
}
}
/// Compatibility state between a local CLI version and a model id.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ModelCompatibility {
/// The known minimum CLI version is satisfied.
Compatible,
/// The CLI version is absent, or the model is not covered by the matrix.
Unknown,
/// The model is covered by the matrix but appears newer than the local CLI.
LikelyTooRecent,
}
/// Origin of a model-catalogue entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ModelCatalogSource {
/// Curated static seed maintained by IdeA.
Catalogue,
/// Best-effort provider API discovery.
Provider,
}
/// Matrix mapping adapter/model ids to their minimum known CLI version.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompatibilityMatrix {
/// Matrix schema/data version.
pub version: u32,
/// Claude Code entries keyed by model id.
#[serde(default)]
pub claude: HashMap<String, String>,
/// Codex CLI entries keyed by model id.
#[serde(default)]
pub codex: HashMap<String, String>,
}
impl CompatibilityMatrix {
/// Looks up the minimum CLI version for an adapter/model pair.
#[must_use]
pub fn minimum_version(&self, adapter: StructuredAdapter, model_id: &str) -> Option<&str> {
match adapter {
StructuredAdapter::Claude => self.claude.get(model_id).map(String::as_str),
StructuredAdapter::Codex => self.codex.get(model_id).map(String::as_str),
StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => None,
}
}
}
/// Evaluates local compatibility using only pure matrix data.
#[must_use]
pub fn evaluate_compatibility(
matrix: &CompatibilityMatrix,
adapter: StructuredAdapter,
model_id: &str,
cli_version: Option<&CliVersion>,
) -> ModelCompatibility {
let Some(cli_version) = cli_version else {
return ModelCompatibility::Unknown;
};
let Some(minimum) = matrix.minimum_version(adapter, model_id) else {
return ModelCompatibility::Unknown;
};
let Ok(minimum) = CliVersion::parse(minimum.to_owned()) else {
return ModelCompatibility::Unknown;
};
if &minimum <= cli_version {
ModelCompatibility::Compatible
} else {
ModelCompatibility::LikelyTooRecent
}
}
/// Errors from pure model-catalogue parsing.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ModelCatalogueError {
/// Version strings must contain at least one numeric segment.
#[error("invalid CLI version: {0}")]
InvalidVersion(String),
}
#[cfg(test)]
mod tests {
use super::*;
fn matrix() -> CompatibilityMatrix {
CompatibilityMatrix {
version: 1,
claude: HashMap::from([("claude-sonnet-5".to_owned(), "1.2.0".to_owned())]),
codex: HashMap::from([("gpt-5-codex".to_owned(), "0.44.0".to_owned())]),
}
}
#[test]
fn cli_versions_compare_by_numeric_parts() {
assert!(CliVersion::parse("codex 0.10.0").unwrap() > CliVersion::parse("0.9.9").unwrap());
assert_eq!(
CliVersion::parse("1.2")
.unwrap()
.cmp(&CliVersion::parse("1.2.0").unwrap()),
Ordering::Equal
);
}
#[test]
fn compatibility_is_unknown_without_version_or_matrix_entry() {
let matrix = matrix();
assert_eq!(
evaluate_compatibility(&matrix, StructuredAdapter::Codex, "gpt-5-codex", None),
ModelCompatibility::Unknown
);
assert_eq!(
evaluate_compatibility(
&matrix,
StructuredAdapter::Codex,
"future-model",
Some(&CliVersion::parse("999.0.0").unwrap())
),
ModelCompatibility::Unknown
);
}
#[test]
fn compatibility_detects_supported_and_too_recent_models() {
let matrix = matrix();
assert_eq!(
evaluate_compatibility(
&matrix,
StructuredAdapter::Codex,
"gpt-5-codex",
Some(&CliVersion::parse("0.44.0").unwrap())
),
ModelCompatibility::Compatible
);
assert_eq!(
evaluate_compatibility(
&matrix,
StructuredAdapter::Claude,
"claude-sonnet-5",
Some(&CliVersion::parse("1.1.9").unwrap())
),
ModelCompatibility::LikelyTooRecent
);
}
}

View File

@ -45,6 +45,7 @@ use crate::issue::{
use crate::markdown::MarkdownDoc;
use crate::mcp_tool_permissions::ProjectMcpToolPermissions;
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
use crate::model_catalogue::{CliVersion, CompatibilityMatrix};
use crate::model_server::{
HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus,
};
@ -54,7 +55,7 @@ use crate::plugin::{
PluginMcpStatusSet, PluginPackageRef, PluginRegistry, RelativePath, RemovalOutcome,
StagedPluginPackage,
};
use crate::profile::{AgentProfile, EmbedderProfile};
use crate::profile::{AgentProfile, EmbedderProfile, StructuredAdapter};
use crate::project::{Project, ProjectPath};
use crate::remote::RemoteKind;
use crate::skill::{Skill, SkillScope};
@ -1245,6 +1246,37 @@ pub trait ProcessSpawner: Send + Sync {
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
}
/// Read a local structured CLI version using only the allowed `--version` probe.
#[async_trait]
pub trait CliVersionReader: Send + Sync {
/// Best-effort local CLI version lookup.
///
/// # Errors
/// Returns a string suitable for non-blocking catalogue warnings.
async fn read_cli_version(
&self,
adapter: StructuredAdapter,
) -> Result<Option<CliVersion>, String>;
}
/// Best-effort provider API model catalogue.
#[async_trait]
pub trait ProviderModelCatalogue: Send + Sync {
/// Lists provider model ids for an adapter. `Ok(Vec::new())` means no key or
/// unsupported provider and is not a warning-worthy failure.
///
/// # Errors
/// Returns a string suitable for non-blocking catalogue warnings.
async fn list_provider_models(&self, adapter: StructuredAdapter)
-> Result<Vec<String>, String>;
}
/// Source of the versioned compatibility matrix.
pub trait CompatibilityMatrixSource: Send + Sync {
/// Returns matrix data and any fallback warnings.
fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec<String>);
}
/// Probe readiness of an OpenAI-compatible model server.
#[async_trait]
pub trait ModelServerProbe: Send + Sync {
@ -1279,6 +1311,22 @@ pub struct ModelArtifactResolution {
pub cache_hit: bool,
}
/// Cache state for a model artifact managed by IdeA.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelArtifactState {
/// Artifact source is not managed by the downloader.
NotManaged,
/// Managed artifact is not present in cache.
Missing,
/// Managed artifact is present in cache.
Downloaded {
/// Local path used to launch the model.
path: ModelPath,
/// Total on-disk bytes when known.
size_bytes: Option<u64>,
},
}
/// Cooperative cancellation token for model artifact resolution.
#[derive(Debug, Clone, Default)]
pub struct ModelArtifactCancel {
@ -1307,6 +1355,15 @@ impl ModelArtifactCancel {
/// Resolves or downloads a model artifact before starting a model server.
#[async_trait]
pub trait ModelArtifactDownloader: Send + Sync {
/// Returns the current cache state for a Hugging Face model reference.
///
/// # Errors
/// [`ModelServerError`] when the cache cannot be inspected.
async fn hf_model_state(
&self,
repo: &HfModelRef,
) -> Result<ModelArtifactState, ModelServerError>;
/// Resolves a Hugging Face model to a local artifact path.
///
/// # Errors
@ -1317,6 +1374,14 @@ pub trait ModelArtifactDownloader: Send + Sync {
progress: Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
cancel: ModelArtifactCancel,
) -> Result<ModelArtifactResolution, ModelServerError>;
/// Deletes the cached artifact for a Hugging Face model reference.
///
/// Deleting a missing artifact is a successful no-op.
///
/// # Errors
/// [`ModelServerError`] when deletion fails.
async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError>;
}
/// Manages local long-lived child processes.

View File

@ -26,6 +26,7 @@ pub mod input;
pub mod inspector;
pub mod issues;
pub mod mailbox;
pub mod model_catalogue;
pub mod model_server;
pub mod orchestrator;
pub mod pair_attempt_limiter;
@ -68,6 +69,9 @@ pub use inspector::{
};
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
pub use mailbox::InMemoryMailbox;
pub use model_catalogue::{
EmbeddedCompatibilityMatrix, HttpProviderModelCatalogue, ProcessCliVersionReader,
};
pub use model_server::{
FsModelServerRegistry, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, LlamaCppRuntime,
LocalManagedProcess,

View File

@ -0,0 +1,331 @@
//! Concrete adapters for structured model-catalogue enrichment.
use std::collections::BTreeSet;
use std::env;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use domain::model_catalogue::{CliVersion, CompatibilityMatrix};
use domain::ports::{
CliVersionReader, CompatibilityMatrixSource, ProcessSpawner, ProviderModelCatalogue, SpawnSpec,
};
use domain::profile::StructuredAdapter;
use domain::project::ProjectPath;
use serde::Deserialize;
const VERSION_TIMEOUT: Duration = Duration::from_millis(800);
const PROVIDER_TIMEOUT: Duration = Duration::from_millis(1_500);
const EMBEDDED_MATRIX: &str = include_str!("model_compatibility_matrix.json");
/// Reads local CLI versions through `codex --version` / `claude --version`.
#[derive(Clone)]
pub struct ProcessCliVersionReader {
spawner: Arc<dyn ProcessSpawner>,
}
impl ProcessCliVersionReader {
/// Builds the adapter from the process-spawner port.
#[must_use]
pub fn new(spawner: Arc<dyn ProcessSpawner>) -> Self {
Self { spawner }
}
fn spec(adapter: StructuredAdapter) -> Option<SpawnSpec> {
let command = match adapter {
StructuredAdapter::Claude => "claude",
StructuredAdapter::Codex => "codex",
StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => return None,
};
Some(SpawnSpec {
command: command.to_owned(),
args: vec!["--version".to_owned()],
cwd: ProjectPath::new("/").expect("root project path is valid"),
env: Vec::new(),
context_plan: None,
sandbox: None,
})
}
}
#[async_trait]
impl CliVersionReader for ProcessCliVersionReader {
async fn read_cli_version(
&self,
adapter: StructuredAdapter,
) -> Result<Option<CliVersion>, String> {
let Some(spec) = Self::spec(adapter) else {
return Ok(None);
};
let command = spec.command.clone();
let output = tokio::time::timeout(VERSION_TIMEOUT, self.spawner.run(spec))
.await
.map_err(|_| format!("{command} --version timed out"))?
.map_err(|e| format!("{command} --version failed: {e}"))?;
if output.status.code != Some(0) {
return Err(format!(
"{command} --version exited with {:?}",
output.status.code
));
}
let text = String::from_utf8_lossy(&output.stdout)
.trim()
.to_owned()
.if_empty_then(|| String::from_utf8_lossy(&output.stderr).trim().to_owned());
if text.is_empty() {
return Ok(None);
}
CliVersion::parse(text)
.map(Some)
.map_err(|e| format!("{command} --version was not parseable: {e}"))
}
}
trait EmptyStringExt {
fn if_empty_then(self, fallback: impl FnOnce() -> String) -> String;
}
impl EmptyStringExt for String {
fn if_empty_then(self, fallback: impl FnOnce() -> String) -> String {
if self.is_empty() {
fallback()
} else {
self
}
}
}
/// Provider HTTP catalogue using existing API keys from the process environment.
#[derive(Clone)]
pub struct HttpProviderModelCatalogue {
client: reqwest::Client,
}
impl HttpProviderModelCatalogue {
/// Builds the adapter.
#[must_use]
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
async fn list_openai(&self, key: String) -> Result<Vec<String>, String> {
#[derive(Deserialize)]
struct Response {
data: Vec<Model>,
}
#[derive(Deserialize)]
struct Model {
id: String,
}
let response = tokio::time::timeout(
PROVIDER_TIMEOUT,
self.client
.get("https://api.openai.com/v1/models")
.bearer_auth(key)
.send(),
)
.await
.map_err(|_| "OpenAI model catalogue timed out".to_owned())?
.map_err(|e| format!("OpenAI model catalogue unavailable: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"OpenAI model catalogue returned HTTP {}",
response.status()
));
}
let parsed = response
.json::<Response>()
.await
.map_err(|e| format!("OpenAI model catalogue parse failed: {e}"))?;
Ok(dedup_non_empty(
parsed.data.into_iter().map(|model| model.id),
))
}
async fn list_anthropic(&self, key: String) -> Result<Vec<String>, String> {
#[derive(Deserialize)]
struct Response {
data: Vec<Model>,
}
#[derive(Deserialize)]
struct Model {
id: String,
}
let response = tokio::time::timeout(
PROVIDER_TIMEOUT,
self.client
.get("https://api.anthropic.com/v1/models")
.header("x-api-key", key)
.header("anthropic-version", "2023-06-01")
.send(),
)
.await
.map_err(|_| "Anthropic model catalogue timed out".to_owned())?
.map_err(|e| format!("Anthropic model catalogue unavailable: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"Anthropic model catalogue returned HTTP {}",
response.status()
));
}
let parsed = response
.json::<Response>()
.await
.map_err(|e| format!("Anthropic model catalogue parse failed: {e}"))?;
Ok(dedup_non_empty(
parsed.data.into_iter().map(|model| model.id),
))
}
}
impl Default for HttpProviderModelCatalogue {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl ProviderModelCatalogue for HttpProviderModelCatalogue {
async fn list_provider_models(
&self,
adapter: StructuredAdapter,
) -> Result<Vec<String>, String> {
match adapter {
StructuredAdapter::Codex => match env::var("OPENAI_API_KEY") {
Ok(key) if !key.trim().is_empty() => self.list_openai(key).await,
_ => Ok(Vec::new()),
},
StructuredAdapter::Claude => match env::var("ANTHROPIC_API_KEY") {
Ok(key) if !key.trim().is_empty() => self.list_anthropic(key).await,
_ => Ok(Vec::new()),
},
StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => Ok(Vec::new()),
}
}
}
fn dedup_non_empty(values: impl Iterator<Item = String>) -> Vec<String> {
values
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
/// Compatibility matrix source backed by an embedded JSON seed and optional
/// app-data override.
#[derive(Debug, Clone)]
pub struct EmbeddedCompatibilityMatrix {
override_path: Option<PathBuf>,
}
impl EmbeddedCompatibilityMatrix {
/// Builds a matrix source with no override.
#[must_use]
pub const fn new() -> Self {
Self {
override_path: None,
}
}
/// Builds a matrix source reading `model-compat.json` from the app data dir
/// before falling back to the embedded seed.
#[must_use]
pub fn with_app_data_dir(app_data_dir: impl Into<PathBuf>) -> Self {
Self {
override_path: Some(app_data_dir.into().join("model-compat.json")),
}
}
fn embedded() -> CompatibilityMatrix {
serde_json::from_str(EMBEDDED_MATRIX).expect("embedded compatibility matrix is valid")
}
}
impl Default for EmbeddedCompatibilityMatrix {
fn default() -> Self {
Self::new()
}
}
impl CompatibilityMatrixSource for EmbeddedCompatibilityMatrix {
fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec<String>) {
let Some(path) = &self.override_path else {
return (Self::embedded(), Vec::new());
};
match std::fs::read_to_string(path) {
Ok(raw) => match serde_json::from_str::<CompatibilityMatrix>(&raw) {
Ok(matrix) => (matrix, Vec::new()),
Err(e) => (
Self::embedded(),
vec![format!(
"model compatibility override ignored because it is invalid: {e}"
)],
),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (Self::embedded(), Vec::new()),
Err(e) => (
Self::embedded(),
vec![format!(
"model compatibility override ignored because it is unreadable: {e}"
)],
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::ports::{ExitStatus, Output, ProcessError};
use std::sync::Mutex;
struct RecordingSpawner {
specs: Mutex<Vec<SpawnSpec>>,
}
#[async_trait]
impl ProcessSpawner for RecordingSpawner {
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError> {
self.specs.lock().unwrap().push(spec);
Ok(Output {
status: ExitStatus { code: Some(0) },
stdout: b"codex-cli 0.45.1\n".to_vec(),
stderr: Vec::new(),
})
}
}
#[tokio::test]
async fn cli_version_reader_runs_only_version_probe() {
let spawner = Arc::new(RecordingSpawner {
specs: Mutex::new(Vec::new()),
});
let reader = ProcessCliVersionReader::new(spawner.clone());
let version = reader
.read_cli_version(StructuredAdapter::Codex)
.await
.unwrap()
.unwrap();
assert_eq!(version.raw, "codex-cli 0.45.1");
let specs = spawner.specs.lock().unwrap();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].command, "codex");
assert_eq!(specs[0].args, vec!["--version"]);
}
#[test]
fn embedded_matrix_is_valid() {
let (matrix, warnings) = EmbeddedCompatibilityMatrix::new().compatibility_matrix();
assert!(warnings.is_empty());
assert!(matrix.codex.contains_key("gpt-5-codex"));
assert!(matrix.claude.contains_key("claude-sonnet-5"));
}
}

View File

@ -0,0 +1,13 @@
{
"version": 1,
"claude": {
"claude-sonnet-5": "1.0.0",
"claude-opus-4-8": "1.0.0",
"claude-haiku-4-5-20251001": "1.0.0"
},
"codex": {
"gpt-5-codex": "0.1.0",
"gpt-5": "0.1.0",
"gpt-5-mini": "0.1.0"
}
}

View File

@ -2,7 +2,7 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
@ -10,6 +10,7 @@ use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt;
use tokio::process::{Child, Command};
use tokio::sync::Mutex as AsyncMutex;
use domain::model_server::{
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
@ -17,9 +18,9 @@ use domain::model_server::{
};
use domain::ports::{
FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel, ModelArtifactDownloader,
ModelArtifactProgress, ModelArtifactResolution, ModelServerArgv, ModelServerError,
ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath,
SpawnSpec,
ModelArtifactProgress, ModelArtifactResolution, ModelArtifactState, ModelServerArgv,
ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus,
RemotePath, SpawnSpec,
};
use domain::{LocalModelServerId, ProjectPath, StopPolicy};
@ -76,6 +77,7 @@ fn is_ready(result: Result<reqwest::Response, reqwest::Error>) -> bool {
pub struct HfModelArtifactDownloader {
cache_dir: PathBuf,
client: reqwest::Client,
repo_locks: Arc<Mutex<HashMap<String, Arc<AsyncMutex<()>>>>>,
}
impl HfModelArtifactDownloader {
@ -85,6 +87,7 @@ impl HfModelArtifactDownloader {
Self {
cache_dir: cache_dir.into(),
client: reqwest::Client::new(),
repo_locks: Arc::new(Mutex::new(HashMap::new())),
}
}
@ -149,6 +152,62 @@ impl HfModelArtifactDownloader {
std::fs::write(manifest_path, json)
}
fn lock_for(&self, repo: &HfModelRef) -> Arc<AsyncMutex<()>> {
let mut locks = self.repo_locks.lock().expect("repo locks mutex poisoned");
Arc::clone(
locks
.entry(repo.as_str().to_owned())
.or_insert_with(|| Arc::new(AsyncMutex::new(()))),
)
}
fn cached_state(&self, repo: &HfModelRef) -> Result<ModelArtifactState, ModelServerError> {
let merged_path = self.cache_path_for(repo);
if merged_path.is_file() {
return Ok(ModelArtifactState::Downloaded {
size_bytes: Some(file_size(&merged_path)?),
path: model_path_from_pathbuf(merged_path)?,
});
}
if let Some(paths) = self.cached_shard_set(repo) {
if let Some(first) = paths.first() {
return Ok(ModelArtifactState::Downloaded {
size_bytes: Some(paths_size(&paths)?),
path: model_path_from_pathbuf(first.clone())?,
});
}
}
Ok(ModelArtifactState::Missing)
}
fn delete_cached(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
let merged_path = self.cache_path_for(repo);
if merged_path.is_file() {
remove_file_if_exists(&merged_path)?;
}
let manifest_path = self.manifest_path_for(repo);
if let Ok(raw) = std::fs::read(&manifest_path) {
let manifest: ShardManifest =
serde_json::from_slice(&raw).map_err(|e| ModelServerError::Store(e.to_string()))?;
let dir = self.cache_dir_for(repo);
for filename in manifest.files {
remove_file_if_exists(&dir.join(filename))?;
}
remove_file_if_exists(&manifest_path)?;
}
let repo_dir = self.cache_dir_for(repo);
if repo_dir.is_dir()
&& std::fs::read_dir(&repo_dir)
.map(is_empty_dir)
.unwrap_or(false)
{
std::fs::remove_dir(&repo_dir).map_err(|e| ModelServerError::Store(e.to_string()))?;
}
Ok(())
}
async fn resolve_remote_filenames(
&self,
repo: &HfModelRef,
@ -189,12 +248,23 @@ struct ShardManifest {
#[async_trait]
impl ModelArtifactDownloader for HfModelArtifactDownloader {
async fn hf_model_state(
&self,
repo: &HfModelRef,
) -> Result<ModelArtifactState, ModelServerError> {
let lock = self.lock_for(repo);
let _guard = lock.lock().await;
self.cached_state(repo)
}
async fn resolve_hf_model(
&self,
repo: &HfModelRef,
progress: std::sync::Arc<dyn Fn(ModelArtifactProgress) + Send + Sync>,
cancel: ModelArtifactCancel,
) -> Result<ModelArtifactResolution, ModelServerError> {
let lock = self.lock_for(repo);
let _guard = lock.lock().await;
if cancel.is_cancelled() {
return Err(ModelServerError::Cancelled);
}
@ -300,6 +370,12 @@ impl ModelArtifactDownloader for HfModelArtifactDownloader {
cache_hit: false,
})
}
async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
let lock = self.lock_for(repo);
let _guard = lock.lock().await;
self.delete_cached(repo)
}
}
#[derive(Debug, Deserialize)]
@ -409,6 +485,31 @@ fn model_path_from_pathbuf(path: PathBuf) -> Result<ModelPath, ModelServerError>
.map_err(|e| ModelServerError::Invalid(e.to_string()))
}
fn file_size(path: &Path) -> Result<u64, ModelServerError> {
std::fs::metadata(path)
.map(|metadata| metadata.len())
.map_err(|e| ModelServerError::Store(e.to_string()))
}
fn paths_size(paths: &[PathBuf]) -> Result<u64, ModelServerError> {
paths
.iter()
.map(|path| file_size(path))
.try_fold(0_u64, |acc, size| size.map(|size| acc.saturating_add(size)))
}
fn remove_file_if_exists(path: &Path) -> Result<(), ModelServerError> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(ModelServerError::Store(err.to_string())),
}
}
fn is_empty_dir(entries: std::fs::ReadDir) -> bool {
entries.into_iter().next().is_none()
}
/// Builds `llama-server` argv without shell interpolation.
#[derive(Debug, Default, Clone, Copy)]
pub struct LlamaCppRuntime;

View File

@ -8,8 +8,8 @@ use domain::model_server::{
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, StopPolicy,
};
use domain::ports::{
FileSystem, ModelArtifactCancel, ModelArtifactDownloader, ModelServerRegistry,
ModelServerRuntime, RemotePath,
FileSystem, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactState,
ModelServerRegistry, ModelServerRuntime, RemotePath,
};
use domain::LocalModelServerId;
use infrastructure::{
@ -227,3 +227,41 @@ async fn hf_model_artifact_downloader_resolves_deterministic_local_cache_hit_wit
std::path::Path::new("Qwen--Qwen3-Coder").join("Q4_K_M.gguf")
);
}
#[tokio::test]
async fn hf_model_artifact_downloader_reports_downloaded_cache_state() {
let tmp = TempDir::new();
let downloader = HfModelArtifactDownloader::new(tmp.path());
let repo = HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap();
let path = downloader.cache_path_for(&repo);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"gguf").unwrap();
let state = downloader.hf_model_state(&repo).await.unwrap();
assert_eq!(
state,
ModelArtifactState::Downloaded {
path: ModelPath::new(path.to_string_lossy()).unwrap(),
size_bytes: Some(4),
}
);
}
#[tokio::test]
async fn hf_model_artifact_downloader_deletes_merged_cache_without_config_side_effects() {
let tmp = TempDir::new();
let downloader = HfModelArtifactDownloader::new(tmp.path());
let repo = HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap();
let path = downloader.cache_path_for(&repo);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"gguf").unwrap();
downloader.delete_hf_model(&repo).await.unwrap();
assert!(!path.exists());
assert_eq!(
downloader.hf_model_state(&repo).await.unwrap(),
ModelArtifactState::Missing
);
}

View File

@ -79,18 +79,18 @@ use backend::dto::{
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
ReadConversationPageRequestDto, RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto,
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto,
SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto,
SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto, TicketCreateRequestDto,
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
ResolveAgentPermissionsRequestDto, ResolveAgentSystemPermissionsRequestDto,
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SkillDto, SkillListDto,
SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto,
SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto,
TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto,
TicketListPageInput, TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
@ -2353,6 +2353,8 @@ async fn invoke(
"list_profiles" => invoke_list_profiles(&state.app).await,
"save_profile" => invoke_save_profile(&request.args, &state.app).await,
"list_opencode_providers" => invoke_list_opencode_providers(&state.app),
"list_claude_models" => invoke_list_claude_models(&state.app).await,
"list_codex_models" => invoke_list_codex_models(&state.app).await,
"save_opencode_provider_profile" => {
invoke_save_opencode_provider_profile(&request.args, &state.app).await
}
@ -2606,6 +2608,16 @@ fn invoke_list_opencode_providers(state: &BackendCore) -> Result<Value, ErrorDto
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_list_claude_models(state: &BackendCore) -> Result<Value, ErrorDto> {
let output: ProfileModelCatalogDto = state.list_claude_models.execute().await.into();
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_list_codex_models(state: &BackendCore) -> Result<Value, ErrorDto> {
let output: ProfileModelCatalogDto = state.list_codex_models.execute().await.into();
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_save_opencode_provider_profile(
args: &Value,
state: &BackendCore,
@ -5862,6 +5874,7 @@ mod tests {
project_id,
task_id,
owner_agent_id: owner,
rendezvous: None,
});
let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
@ -7653,6 +7666,8 @@ mod tests {
"list_profiles",
"save_profile",
"list_opencode_providers",
"list_claude_models",
"list_codex_models",
"save_opencode_provider_profile",
"delete_profile",
"configure_profiles",

View File

@ -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<void> {
await this.http.invoke("delete_profile", { profileId });
}
cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise<AgentProfile> {
return this.http.invoke<AgentProfile>("clone_profile_from_seed", {
request: { seedProfileId: input.seedProfileId, name: input.name, model: input.model },
});
}
async listClaudeModels(): Promise<ProfileModelCatalog> {
return normalizeProfileModelCatalog(await this.http.invoke<unknown>("list_claude_models"));
}
async listCodexModels(): Promise<ProfileModelCatalog> {
return normalizeProfileModelCatalog(await this.http.invoke<unknown>("list_codex_models"));
}
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
return this.http.invoke<AgentProfile[]>("configure_profiles", { request: { profiles } });
}
@ -215,6 +229,9 @@ export class HttpModelServerGateway implements ModelServerGateway {
async deleteModelServer(serverId: string): Promise<void> {
await this.http.invoke("delete_model_server", { serverId });
}
async deleteModelArtifact(serverId: string): Promise<void> {
await this.http.invoke("delete_model_artifact", { serverId });
}
previewModelServerCommand(config: LocalModelServerConfig): Promise<ModelServerCommandPreview> {
return this.http.invoke<ModelServerCommandPreview>("preview_model_server_command", { config });
}

View File

@ -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<AgentProfile> {
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<ProfileModelCatalog> {
return {
models: structuredClone(MOCK_CLAUDE_MODELS),
cliVersion: "2.1.220",
warnings: [],
};
}
async listCodexModels(): Promise<ProfileModelCatalog> {
return {
models: structuredClone(MOCK_CODEX_MODELS),
cliVersion: "0.145.0",
warnings: ["Catalogue provider partiellement estime depuis les donnees locales."],
};
}
async cloneOpenCodeProfileFromSeed(
input: CloneOpenCodeProfileFromSeedInput = {},
): Promise<AgentProfile> {
@ -1424,6 +1525,36 @@ export class MockModelServerGateway implements ModelServerGateway {
this.servers = this.servers.filter((s) => s.id !== serverId);
}
async deleteModelArtifact(serverId: string): Promise<void> {
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<ModelServerCommandPreview> {

View File

@ -32,6 +32,10 @@ export class TauriModelServerGateway implements ModelServerGateway {
await invoke("delete_model_server", { serverId });
}
async deleteModelArtifact(serverId: string): Promise<void> {
await invoke("delete_model_artifact", { serverId });
}
previewModelServerCommand(
config: LocalModelServerConfig,
): Promise<ModelServerCommandPreview> {

View File

@ -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<FirstRunState> {
@ -47,6 +50,24 @@ export class TauriProfileGateway implements ProfileGateway {
await invoke("delete_profile", { profileId });
}
cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise<AgentProfile> {
return invoke<AgentProfile>("clone_profile_from_seed", {
request: {
seedProfileId: input.seedProfileId,
name: input.name,
model: input.model,
},
});
}
async listClaudeModels(): Promise<ProfileModelCatalog> {
return normalizeProfileModelCatalog(await invoke<unknown>("list_claude_models"));
}
async listCodexModels(): Promise<ProfileModelCatalog> {
return normalizeProfileModelCatalog(await invoke<unknown>("list_codex_models"));
}
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
return invoke<AgentProfile[]>("configure_profiles", {
request: { profiles },

View File

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

View File

@ -0,0 +1,63 @@
import type {
ModelCatalogSource,
ModelCompatibility,
ProfileModelCatalog,
ProfileModelCatalogEntry,
} from "@/domain";
type PartialCatalogEntry = Partial<ProfileModelCatalogEntry> & {
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<ProfileModelCatalog>)
: {};
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)
: [],
};
}

View File

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

View File

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

View File

@ -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 (
<Panel title="Agents" className="flex flex-col gap-0">
{vm.error && (
@ -326,7 +335,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
<option value=""> select profile </option>
{vm.profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{profileLabel(p)}
</option>
))}
</select>
@ -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) => (
<option key={p.id} value={p.id}>
{p.name}
{profileLabel(p)}
</option>
))}
</select>

View File

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

View File

@ -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 <span className="text-xs font-medium text-muted">{children}</span>;
}
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<OpenCodeProviderCatalogEntry[] | null>(
null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="mt-1 flex flex-col gap-2">
<div
role="radiogroup"
aria-label="mode du profil OpenCode"
className="flex w-fit gap-1 rounded-md border border-border bg-raised p-0.5"
>
{(
[
{ id: "local", label: "Local (llama.cpp)" },
{ id: "cloud", label: "Provider cloud" },
] as const
).map((seg) => {
const active = mode === seg.id;
return (
<button
key={seg.id}
type="button"
role="radio"
aria-checked={active}
onClick={() => setMode(seg.id)}
className={cn(
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
active
? "bg-primary text-on-primary"
: "text-muted hover:text-content",
)}
>
{seg.label}
</button>
);
})}
</div>
{mode === "local" && (
<OpenCodeFields
profile={profile}
errors={errors}
servers={servers}
onChange={onChange}
/>
)}
{mode === "cloud" && (
<OpenCodeProviderFields
profile={profile}
catalog={providerCatalog}
onChange={onChange}
/>
)}
</div>
);
}
/** 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 `<option>` value picking the custom-provider sub-form. */
const CUSTOM_PROVIDER_VALUE = "__custom__";
/** Default AI SDK package pre-filled for a fresh custom-provider draft. */
const DEFAULT_CUSTOM_NPM = "@ai-sdk/openai-compatible";
/**
* The OpenCode **cloud** provider config section (ticket #92): provider ➜
* model (cascading selects fed by the dynamic catalogue, now up to ~170
* entries) ➜ API key, or — via the "Autre / personnalisé" catalogue entry — a
* free-form OpenAI-compatible endpoint (provider id, npm SDK package, base
* URL, model id, optional display name). The key is never pre-filled (create
* or edit) since the backend never returns it — it reseals whatever literal
* it receives on every save, so editing an existing cloud profile requires
* re-entering it every time (§3 of the spec).
*/
function OpenCodeProviderFields({
profile,
catalog,
onChange,
}: {
profile: AgentProfile;
catalog: OpenCodeProviderCatalog;
onChange: (p: AgentProfile) => void;
}) {
const { profile: profileGateway } = useGateways();
const existing = profile.opencodeProvider;
const [mode, setMode] = useState<"catalog" | "custom">(
existing?.custom ? "custom" : "catalog",
);
const [providerId, setProviderId] = useState(existing?.providerId ?? "");
const [model, setModel] = useState(existing?.model ?? "");
const [providerFilter, setProviderFilter] = useState("");
const [customNpm, setCustomNpm] = useState(existing?.custom?.npm ?? DEFAULT_CUSTOM_NPM);
const [customBaseUrl, setCustomBaseUrl] = useState(existing?.custom?.baseUrl ?? "");
const [customDisplayName, setCustomDisplayName] = useState(
existing?.custom?.displayName ?? "",
);
const [apiKey, setApiKey] = useState("");
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<CloudFieldErrors>({});
const isEditing = existing !== undefined;
const models =
catalog.providers?.find((p) => p.providerId === providerId)?.models ?? [];
const catalogReady = catalog.providers !== null && !catalog.loading;
const filteredProviders = (catalog.providers ?? []).filter((p) => {
// Always keep the current selection visible even if it no longer matches
// the filter, so the <select> doesn't silently lose its value.
if (p.providerId === providerId) return true;
const q = providerFilter.trim().toLowerCase();
if (q.length === 0) return true;
return (
p.displayName.toLowerCase().includes(q) ||
p.providerId.toLowerCase().includes(q)
);
});
const saveDisabled =
saving ||
apiKey.length === 0 ||
(mode === "catalog" && (!catalogReady || Boolean(catalog.error)));
async function save() {
const errors: CloudFieldErrors = {};
if (providerId.trim().length === 0) {
errors.providerId = "Le provider est obligatoire.";
}
if (model.trim().length === 0) errors.model = "Le modèle est obligatoire.";
if (mode === "custom") {
if (customNpm.trim().length === 0) errors.npm = "Le paquet npm est obligatoire.";
if (customBaseUrl.trim().length === 0) {
errors.baseUrl = "L'URL de base est obligatoire.";
}
}
if (apiKey.length === 0) errors.apiKey = "La clé API est obligatoire.";
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
setSaving(true);
setSaveError(null);
try {
const saved = await profileGateway.saveOpenCodeProviderProfile({
profile,
providerId: providerId.trim(),
model: model.trim(),
apiKey,
...(mode === "custom"
? {
custom: {
npm: customNpm.trim(),
baseUrl: customBaseUrl.trim(),
displayName:
customDisplayName.trim().length > 0
? customDisplayName.trim()
: undefined,
},
}
: {}),
});
onChange(saved);
setApiKey("");
} catch (e) {
setSaveError(describeError(e));
} finally {
setSaving(false);
}
}
return (
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Provider cloud (OpenCode)
</legend>
{saveError && (
<p role="alert" className="text-sm text-danger">
{saveError}
</p>
)}
{catalog.loading && (
<p className="text-xs text-faint">Chargement des providers</p>
)}
{catalog.error && (
<div className="flex flex-col gap-1">
<p role="alert" className="text-sm text-danger">
Impossible de charger la liste des providers cloud.
</p>
<Button size="sm" onClick={() => catalog.reload()} className="w-fit">
Réessayer
</Button>
</div>
)}
{mode === "catalog" && (
<>
<label className="flex flex-col gap-1">
<Caption>Provider</Caption>
{catalogReady && (
<input
type="text"
aria-label={`${profile.name} provider search`}
placeholder="Rechercher un provider…"
value={providerFilter}
onChange={(e) => setProviderFilter(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-raised px-3 text-xs text-content outline-none"
/>
)}
<select
aria-label={`${profile.name} provider`}
value={providerId}
disabled={!catalogReady}
onChange={(e) => {
const v = e.target.value;
if (v === CUSTOM_PROVIDER_VALUE) {
setMode("custom");
setProviderId("");
setModel("");
} else {
setProviderId(v);
setModel("");
}
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
}}
className={cn(
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
fieldErrors.providerId ? "border-danger" : "border-border",
)}
>
<option value="" disabled>
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"}
</option>
{filteredProviders.map((p) => (
<option key={p.providerId} value={p.providerId}>
{p.displayName}
</option>
))}
<option value={CUSTOM_PROVIDER_VALUE}>Autre / personnalisé</option>
</select>
{fieldErrors.providerId && (
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Modèle</Caption>
<select
aria-label={`${profile.name} model`}
value={model}
disabled={providerId.length === 0}
onChange={(e) => {
setModel(e.target.value);
setFieldErrors((prev) => ({ ...prev, model: undefined }));
}}
className={cn(
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
fieldErrors.model ? "border-danger" : "border-border",
)}
>
<option value="" disabled>
{providerId.length === 0 ? "—" : "Choisir un modèle…"}
</option>
{models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
{fieldErrors.model && (
<small className="text-xs text-danger">{fieldErrors.model}</small>
)}
</label>
</>
)}
{mode === "custom" && (
<fieldset className="flex flex-col gap-2 rounded-md border border-border/50 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Provider personnalisé
</legend>
<Button
variant="ghost"
size="sm"
className="w-fit"
onClick={() => {
setMode("catalog");
setProviderId("");
setModel("");
setFieldErrors({});
}}
>
Choisir un provider du catalogue
</Button>
<label className="flex flex-col gap-1">
<Caption>Identifiant du provider</Caption>
<Input
aria-label={`${profile.name} custom provider id`}
value={providerId}
placeholder="ex. mon-provider"
invalid={Boolean(fieldErrors.providerId)}
onChange={(e) => {
setProviderId(e.target.value);
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
}}
/>
{fieldErrors.providerId && (
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Paquet npm</Caption>
<Input
aria-label={`${profile.name} custom npm package`}
value={customNpm}
placeholder={DEFAULT_CUSTOM_NPM}
invalid={Boolean(fieldErrors.npm)}
onChange={(e) => {
setCustomNpm(e.target.value);
setFieldErrors((prev) => ({ ...prev, npm: undefined }));
}}
/>
{fieldErrors.npm && (
<small className="text-xs text-danger">{fieldErrors.npm}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>URL de base</Caption>
<Input
aria-label={`${profile.name} custom base url`}
value={customBaseUrl}
placeholder="https://api.mon-provider.example/v1"
invalid={Boolean(fieldErrors.baseUrl)}
onChange={(e) => {
setCustomBaseUrl(e.target.value);
setFieldErrors((prev) => ({ ...prev, baseUrl: undefined }));
}}
/>
{fieldErrors.baseUrl && (
<small className="text-xs text-danger">{fieldErrors.baseUrl}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Modèle</Caption>
<Input
aria-label={`${profile.name} model`}
value={model}
placeholder="ex. mon-modele-1"
invalid={Boolean(fieldErrors.model)}
onChange={(e) => {
setModel(e.target.value);
setFieldErrors((prev) => ({ ...prev, model: undefined }));
}}
/>
{fieldErrors.model && (
<small className="text-xs text-danger">{fieldErrors.model}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Libellé du modèle (optionnel)</Caption>
<Input
aria-label={`${profile.name} custom display name`}
value={customDisplayName}
placeholder="ex. Mon modèle"
onChange={(e) => setCustomDisplayName(e.target.value)}
/>
</label>
</fieldset>
)}
<label className="flex flex-col gap-1">
<Caption>Clé API</Caption>
<div className="flex items-center gap-1">
<Input
aria-label={`${profile.name} api key`}
type={showKey ? "text" : "password"}
value={apiKey}
placeholder={
isEditing
? "Ressaisissez la clé API pour confirmer l'enregistrement"
: "ex. sk-ant-…"
}
invalid={Boolean(fieldErrors.apiKey)}
onChange={(e) => {
setApiKey(e.target.value);
setFieldErrors((prev) => ({ ...prev, apiKey: undefined }));
}}
/>
<IconButton
size="sm"
aria-label="afficher/masquer la clé API"
onClick={() => setShowKey((v) => !v)}
>
{showKey ? "🙈" : "👁"}
</IconButton>
</div>
{fieldErrors.apiKey && (
<small className="text-xs text-danger">{fieldErrors.apiKey}</small>
)}
<small className="text-xs text-faint">
Jamais affichée ni renvoyée par IdeA une fois enregistrée ; stockée
chiffrée localement.
</small>
{isEditing && (
<small className="text-xs text-muted">
Pour des raisons de sécurité, la clé n&apos;est jamais réaffichée :
ressaisissez-la à chaque modification de ce profil.
</small>
)}
</label>
<Button
variant="primary"
size="sm"
loading={saving}
disabled={saveDisabled}
onClick={() => void save()}
className="w-fit"
>
Enregistrer
</Button>
</fieldset>
);
}
function CliModelField({
profile,
onChange,
@ -829,114 +317,6 @@ function CliModelField({
);
}
/**
* The OpenCode + llama.cpp config section: the base URL of the local
* `llama-server` (`/v1`), the model it serves, and an optional API key (the key
* itself — a local llama.cpp usually needs none). Rendered only for a `openCode`
* profile; edits flow back through `onChange` as a patched `opencode`.
*/
function OpenCodeFields({
profile,
errors,
servers,
onChange,
}: {
profile: AgentProfile;
errors: ProfileErrors;
/** Declared local model servers (F35.2), for the binding dropdown. */
servers: LocalModelServerConfig[];
onChange: (p: AgentProfile) => void;
}) {
const opencode = profile.opencode ?? defaultOpenCodeConfig();
const patch = (next: Partial<OpenCodeConfig>) =>
onChange({ ...profile, opencode: { ...opencode, ...next } });
return (
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Local model (OpenCode + llama.cpp)
</legend>
<label className="flex flex-col gap-1">
<Caption>Base URL</Caption>
<Input
aria-label={`${profile.name} base url`}
value={opencode.baseURL}
placeholder="http://localhost:8080/v1"
invalid={Boolean(errors.baseURL)}
onChange={(e) => patch({ baseURL: e.target.value })}
/>
{errors.baseURL && (
<small className="text-xs text-danger">{errors.baseURL}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Model</Caption>
<Input
aria-label={`${profile.name} model`}
value={opencode.model}
placeholder="qwen3-coder-30b"
invalid={Boolean(errors.model)}
onChange={(e) => patch({ model: e.target.value })}
/>
{errors.model && <small className="text-xs text-danger">{errors.model}</small>}
</label>
<label className="flex flex-col gap-1">
<Caption>API key (optional a local llama.cpp needs none)</Caption>
<Input
aria-label={`${profile.name} api key`}
value={opencode.apiKey ?? ""}
placeholder="e.g. sk-no-key"
onChange={(e) => {
const v = e.target.value;
patch({ apiKey: v.length === 0 ? undefined : v });
}}
/>
</label>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`${profile.name} reasoning`}
// Effective backend default is `true`; treat omitted as enabled.
checked={opencode.reasoning ?? true}
onChange={(e) => patch({ reasoning: e.target.checked })}
className="accent-primary"
/>
<Caption>Reasoning</Caption>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`${profile.name} attachment`}
// Effective backend default is `false`.
checked={opencode.attachment ?? false}
onChange={(e) => patch({ attachment: e.target.checked })}
className="accent-primary"
/>
<Caption>Attachments</Caption>
</label>
</div>
<label className="flex flex-col gap-1">
<Caption>
Local model server (bind to a managed llama.cpp server, or none)
</Caption>
<ModelServerSelect
ariaLabel={`${profile.name} local model server`}
servers={servers}
value={opencode.localModelServerId}
onChange={(serverId) => patch({ localModelServerId: serverId })}
/>
</label>
</fieldset>
);
}
/** Parses a number field: blank ⇒ `undefined` (backend applies its default). */
function parseOptInt(raw: string): number | undefined {
const trimmed = raw.trim();

View File

@ -0,0 +1,629 @@
import { useCallback, useEffect, useState } from "react";
import type {
AgentProfile,
GatewayError,
LocalModelServerConfig,
OpenCodeConfig,
OpenCodeProviderCatalogEntry,
} from "@/domain";
import { useGateways } from "@/app/di";
import { Button, IconButton, Input, cn } from "@/shared";
import { ModelServerSelect } from "@/features/model-servers";
import {
defaultOpenCodeConfig,
type ProfileErrors,
} from "./profile";
/** A small caption above a control. */
function Caption({ children }: { children: React.ReactNode }) {
return <span className="text-xs font-medium text-muted">{children}</span>;
}
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). */
export interface OpenCodeProviderCatalog {
providers: OpenCodeProviderCatalogEntry[] | null;
loading: boolean;
error: string | null;
reload: () => void;
}
/**
* Loads the static OpenCode cloud-provider catalogue once for the whole form
* surface, so Cloud OpenCode rows share the same provider/model options.
*/
export function useOpenCodeProviderCatalog(): OpenCodeProviderCatalog {
const { profile } = useGateways();
const [providers, setProviders] = useState<OpenCodeProviderCatalogEntry[] | null>(
null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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() };
}
/**
* Segmented control choosing whether an OpenCode profile runs against the local
* `llama.cpp` endpoint or a cloud provider from the OpenCode registry.
*/
export 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",
);
function selectMode(next: "local" | "cloud") {
setMode(next);
if (next === "local" && profile.opencodeProvider) {
onChange({
...profile,
opencode: profile.opencode ?? defaultOpenCodeConfig(),
opencodeProvider: undefined,
});
}
}
return (
<div className="mt-1 flex flex-col gap-2">
<div
role="radiogroup"
aria-label="mode du profil OpenCode"
className="flex w-fit gap-1 rounded-md border border-border bg-raised p-0.5"
>
{(
[
{ id: "local", label: "Local (llama.cpp)" },
{ id: "cloud", label: "Provider cloud" },
] as const
).map((seg) => {
const active = mode === seg.id;
return (
<button
key={seg.id}
type="button"
role="radio"
aria-checked={active}
onClick={() => selectMode(seg.id)}
className={cn(
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
active
? "bg-primary text-on-primary"
: "text-muted hover:text-content",
)}
>
{seg.label}
</button>
);
})}
</div>
{mode === "local" && (
<OpenCodeFields
profile={profile}
errors={errors}
servers={servers}
onChange={onChange}
/>
)}
{mode === "cloud" && (
<OpenCodeProviderFields
profile={profile}
catalog={providerCatalog}
onChange={onChange}
/>
)}
</div>
);
}
/** 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 `<option>` value picking the custom-provider sub-form. */
const CUSTOM_PROVIDER_VALUE = "__custom__";
/** Default AI SDK package pre-filled for a fresh custom-provider draft. */
const DEFAULT_CUSTOM_NPM = "@ai-sdk/openai-compatible";
function OpenCodeProviderFields({
profile,
catalog,
onChange,
}: {
profile: AgentProfile;
catalog: OpenCodeProviderCatalog;
onChange: (p: AgentProfile) => void;
}) {
const { profile: profileGateway } = useGateways();
const existing = profile.opencodeProvider;
const [mode, setMode] = useState<"catalog" | "custom">(
existing?.custom ? "custom" : "catalog",
);
const [providerId, setProviderId] = useState(existing?.providerId ?? "");
const [model, setModel] = useState(existing?.model ?? "");
const [providerFilter, setProviderFilter] = useState("");
const [customNpm, setCustomNpm] = useState(existing?.custom?.npm ?? DEFAULT_CUSTOM_NPM);
const [customBaseUrl, setCustomBaseUrl] = useState(existing?.custom?.baseUrl ?? "");
const [customDisplayName, setCustomDisplayName] = useState(
existing?.custom?.displayName ?? "",
);
const [apiKey, setApiKey] = useState("");
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<CloudFieldErrors>({});
const isEditing = existing !== undefined;
const models =
catalog.providers?.find((p) => p.providerId === providerId)?.models ?? [];
const catalogReady = catalog.providers !== null && !catalog.loading;
const filteredProviders = (catalog.providers ?? []).filter((p) => {
if (p.providerId === providerId) return true;
const q = providerFilter.trim().toLowerCase();
if (q.length === 0) return true;
return (
p.displayName.toLowerCase().includes(q) ||
p.providerId.toLowerCase().includes(q)
);
});
const saveDisabled =
saving ||
apiKey.length === 0 ||
(mode === "catalog" && (!catalogReady || Boolean(catalog.error)));
async function save() {
const errors: CloudFieldErrors = {};
if (providerId.trim().length === 0) {
errors.providerId = "Le provider est obligatoire.";
}
if (model.trim().length === 0) errors.model = "Le modèle est obligatoire.";
if (mode === "custom") {
if (customNpm.trim().length === 0) errors.npm = "Le paquet npm est obligatoire.";
if (customBaseUrl.trim().length === 0) {
errors.baseUrl = "L'URL de base est obligatoire.";
}
}
if (apiKey.length === 0) errors.apiKey = "La clé API est obligatoire.";
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
setSaving(true);
setSaveError(null);
try {
const saved = await profileGateway.saveOpenCodeProviderProfile({
profile,
providerId: providerId.trim(),
model: model.trim(),
apiKey,
...(mode === "custom"
? {
custom: {
npm: customNpm.trim(),
baseUrl: customBaseUrl.trim(),
displayName:
customDisplayName.trim().length > 0
? customDisplayName.trim()
: undefined,
},
}
: {}),
});
onChange(saved);
setApiKey("");
} catch (e) {
setSaveError(describeError(e));
} finally {
setSaving(false);
}
}
return (
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Provider cloud (OpenCode)
</legend>
{saveError && (
<p role="alert" className="text-sm text-danger">
{saveError}
</p>
)}
{catalog.loading && (
<p className="text-xs text-faint">Chargement des providers</p>
)}
{catalog.error && (
<div className="flex flex-col gap-1">
<p role="alert" className="text-sm text-danger">
Impossible de charger la liste des providers cloud.
</p>
<Button size="sm" onClick={() => catalog.reload()} className="w-fit">
Réessayer
</Button>
</div>
)}
{mode === "catalog" && (
<>
<label className="flex flex-col gap-1">
<Caption>Provider</Caption>
{catalogReady && (
<input
type="text"
aria-label={`${profile.name} provider search`}
placeholder="Rechercher un provider…"
value={providerFilter}
onChange={(e) => setProviderFilter(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-raised px-3 text-xs text-content outline-none"
/>
)}
<select
aria-label={`${profile.name} provider`}
value={providerId}
disabled={!catalogReady}
onChange={(e) => {
const v = e.target.value;
if (v === CUSTOM_PROVIDER_VALUE) {
setMode("custom");
setProviderId("");
setModel("");
} else {
setProviderId(v);
setModel("");
}
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
}}
className={cn(
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
fieldErrors.providerId ? "border-danger" : "border-border",
)}
>
<option value="" disabled>
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"}
</option>
{filteredProviders.map((p) => (
<option key={p.providerId} value={p.providerId}>
{p.displayName}
</option>
))}
<option value={CUSTOM_PROVIDER_VALUE}>Autre / personnalisé</option>
</select>
{fieldErrors.providerId && (
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Modèle</Caption>
<select
aria-label={`${profile.name} model`}
value={model}
disabled={providerId.length === 0}
onChange={(e) => {
setModel(e.target.value);
setFieldErrors((prev) => ({ ...prev, model: undefined }));
}}
className={cn(
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
fieldErrors.model ? "border-danger" : "border-border",
)}
>
<option value="" disabled>
{providerId.length === 0 ? "—" : "Choisir un modèle…"}
</option>
{models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
{fieldErrors.model && (
<small className="text-xs text-danger">{fieldErrors.model}</small>
)}
</label>
</>
)}
{mode === "custom" && (
<fieldset className="flex flex-col gap-2 rounded-md border border-border/50 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Provider personnalisé
</legend>
<Button
variant="ghost"
size="sm"
className="w-fit"
onClick={() => {
setMode("catalog");
setProviderId("");
setModel("");
setFieldErrors({});
}}
>
Choisir un provider du catalogue
</Button>
<label className="flex flex-col gap-1">
<Caption>Identifiant du provider</Caption>
<Input
aria-label={`${profile.name} custom provider id`}
value={providerId}
placeholder="ex. mon-provider"
invalid={Boolean(fieldErrors.providerId)}
onChange={(e) => {
setProviderId(e.target.value);
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
}}
/>
{fieldErrors.providerId && (
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Paquet npm</Caption>
<Input
aria-label={`${profile.name} custom npm package`}
value={customNpm}
placeholder={DEFAULT_CUSTOM_NPM}
invalid={Boolean(fieldErrors.npm)}
onChange={(e) => {
setCustomNpm(e.target.value);
setFieldErrors((prev) => ({ ...prev, npm: undefined }));
}}
/>
{fieldErrors.npm && (
<small className="text-xs text-danger">{fieldErrors.npm}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>URL de base</Caption>
<Input
aria-label={`${profile.name} custom base url`}
value={customBaseUrl}
placeholder="https://api.mon-provider.example/v1"
invalid={Boolean(fieldErrors.baseUrl)}
onChange={(e) => {
setCustomBaseUrl(e.target.value);
setFieldErrors((prev) => ({ ...prev, baseUrl: undefined }));
}}
/>
{fieldErrors.baseUrl && (
<small className="text-xs text-danger">{fieldErrors.baseUrl}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Modèle</Caption>
<Input
aria-label={`${profile.name} model`}
value={model}
placeholder="ex. mon-modele-1"
invalid={Boolean(fieldErrors.model)}
onChange={(e) => {
setModel(e.target.value);
setFieldErrors((prev) => ({ ...prev, model: undefined }));
}}
/>
{fieldErrors.model && (
<small className="text-xs text-danger">{fieldErrors.model}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Libellé du modèle (optionnel)</Caption>
<Input
aria-label={`${profile.name} custom display name`}
value={customDisplayName}
placeholder="ex. Mon modèle"
onChange={(e) => setCustomDisplayName(e.target.value)}
/>
</label>
</fieldset>
)}
<label className="flex flex-col gap-1">
<Caption>Clé API</Caption>
<div className="flex items-center gap-1">
<Input
aria-label={`${profile.name} api key`}
type={showKey ? "text" : "password"}
value={apiKey}
placeholder={
isEditing
? "Ressaisissez la clé API pour confirmer l'enregistrement"
: "ex. sk-ant-…"
}
invalid={Boolean(fieldErrors.apiKey)}
onChange={(e) => {
setApiKey(e.target.value);
setFieldErrors((prev) => ({ ...prev, apiKey: undefined }));
}}
/>
<IconButton
size="sm"
aria-label="afficher/masquer la clé API"
onClick={() => setShowKey((v) => !v)}
>
{showKey ? "🙈" : "👁"}
</IconButton>
</div>
{fieldErrors.apiKey && (
<small className="text-xs text-danger">{fieldErrors.apiKey}</small>
)}
<small className="text-xs text-faint">
Jamais affichée ni renvoyée par IdeA une fois enregistrée ; stockée
chiffrée localement.
</small>
{isEditing && (
<small className="text-xs text-muted">
Pour des raisons de sécurité, la clé n&apos;est jamais réaffichée :
ressaisissez-la à chaque modification de ce profil.
</small>
)}
</label>
<Button
variant="primary"
size="sm"
loading={saving}
disabled={saveDisabled}
onClick={() => void save()}
className="w-fit"
>
Enregistrer
</Button>
</fieldset>
);
}
function OpenCodeFields({
profile,
errors,
servers,
onChange,
}: {
profile: AgentProfile;
errors: ProfileErrors;
/** Declared local model servers (F35.2), for the binding dropdown. */
servers: LocalModelServerConfig[];
onChange: (p: AgentProfile) => void;
}) {
const opencode = profile.opencode ?? defaultOpenCodeConfig();
const patch = (next: Partial<OpenCodeConfig>) =>
onChange({
...profile,
opencode: { ...opencode, ...next },
opencodeProvider: undefined,
});
return (
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Local model (OpenCode + llama.cpp)
</legend>
<label className="flex flex-col gap-1">
<Caption>Base URL</Caption>
<Input
aria-label={`${profile.name} base url`}
value={opencode.baseURL}
placeholder="http://localhost:8080/v1"
invalid={Boolean(errors.baseURL)}
onChange={(e) => patch({ baseURL: e.target.value })}
/>
{errors.baseURL && (
<small className="text-xs text-danger">{errors.baseURL}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Model</Caption>
<Input
aria-label={`${profile.name} model`}
value={opencode.model}
placeholder="qwen3-coder-30b"
invalid={Boolean(errors.model)}
onChange={(e) => patch({ model: e.target.value })}
/>
{errors.model && <small className="text-xs text-danger">{errors.model}</small>}
</label>
<label className="flex flex-col gap-1">
<Caption>API key (optional a local llama.cpp needs none)</Caption>
<Input
aria-label={`${profile.name} api key`}
value={opencode.apiKey ?? ""}
placeholder="e.g. sk-no-key"
onChange={(e) => {
const v = e.target.value;
patch({ apiKey: v.length === 0 ? undefined : v });
}}
/>
</label>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`${profile.name} reasoning`}
checked={opencode.reasoning ?? true}
onChange={(e) => patch({ reasoning: e.target.checked })}
className="accent-primary"
/>
<Caption>Reasoning</Caption>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`${profile.name} attachment`}
checked={opencode.attachment ?? false}
onChange={(e) => patch({ attachment: e.target.checked })}
className="accent-primary"
/>
<Caption>Attachments</Caption>
</label>
</div>
<label className="flex flex-col gap-1">
<Caption>
Local model server (bind to a managed llama.cpp server, or none)
</Caption>
<ModelServerSelect
ariaLabel={`${profile.name} local model server`}
servers={servers}
value={opencode.localModelServerId}
onChange={(serverId) => patch({ localModelServerId: serverId })}
/>
</label>
</fieldset>
);
}

View File

@ -0,0 +1,224 @@
import { describe, expect, it } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { DIProvider } from "@/app/di";
import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import type { LocalModelServerConfig, ProfileModelCatalog } from "@/domain";
import { ProfilesSettings } from "./ProfilesSettings";
function renderSettings(
profile: MockProfileGateway = new MockProfileGateway(),
modelServer: MockModelServerGateway = new MockModelServerGateway(),
) {
return {
profile,
modelServer,
...render(
<DIProvider gateways={{ profile, modelServer } as unknown as Gateways}>
<ProfilesSettings />
</DIProvider>,
),
};
}
async function waitReady() {
await waitFor(() =>
expect(
(screen.getByRole("button", { name: "Creer un profil" }) as HTMLButtonElement)
.disabled,
).toBe(false),
);
}
async function createProfile() {
const before = screen.queryAllByRole("listitem").length;
fireEvent.click(screen.getByRole("button", { name: "Creer un profil" }));
await waitFor(() => expect(screen.getAllByRole("listitem")).toHaveLength(before + 1));
}
describe("ProfilesSettings", () => {
it("creates multiple named Codex and Claude profiles with different models", async () => {
const { profile } = renderSettings();
await waitReady();
await createProfile();
await createProfile();
let rows = screen.getAllByRole("listitem");
fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), {
target: { value: "Codex mini" },
});
fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), {
target: { value: "gpt-5-mini" },
});
fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" }));
fireEvent.click(screen.getByRole("tab", { name: "Claude" }));
await waitReady();
await createProfile();
await createProfile();
rows = screen.getAllByRole("listitem");
fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), {
target: { value: "Claude Opus" },
});
fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), {
target: { value: "claude-opus-4-8" },
});
fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.filter((p) => p.structuredAdapter === "codex")).toHaveLength(2);
expect(saved.filter((p) => p.structuredAdapter === "claude")).toHaveLength(2);
expect(saved.map((p) => p.model)).toEqual(
expect.arrayContaining([
"gpt-5-codex",
"gpt-5-mini",
"claude-sonnet-5",
"claude-opus-4-8",
]),
);
});
});
it("duplicates from an existing profile with '<name> copy' and preserves the model", async () => {
const { profile } = renderSettings();
await waitReady();
await createProfile();
const row = screen.getAllByRole("listitem")[0];
fireEvent.click(within(row).getByRole("button", { name: "Dupliquer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.some((p) => p.name === "OpenAI Codex CLI copy copy")).toBe(true);
expect(saved.filter((p) => p.model === "gpt-5-codex")).toHaveLength(2);
});
});
it("keeps manual model entry available when the catalogue fails", async () => {
class CatalogueDownProfileGateway extends MockProfileGateway {
listCodexModels(): Promise<ProfileModelCatalog> {
return Promise.reject(new Error("catalogue down"));
}
}
renderSettings(new CatalogueDownProfileGateway());
await waitReady();
expect(await screen.findByText(/Catalogue provider indisponible/)).toBeTruthy();
await createProfile();
const model = within(screen.getAllByRole("listitem")[0]).getByLabelText(
/modele du profil/,
) as HTMLInputElement;
fireEvent.change(model, { target: { value: "future-codex-model" } });
expect(model.value).toBe("future-codex-model");
expect(screen.getAllByText(/Catalogue provider indisponible/).length).toBeGreaterThan(0);
});
it("shows compatibility states in suggestions and contextual help", async () => {
renderSettings();
await waitReady();
await createProfile();
const row = screen.getAllByRole("listitem")[0];
const model = within(row).getByLabelText(/modele du profil/) as HTMLInputElement;
fireEvent.focus(model);
expect(await within(row).findByText("Compatible")).toBeTruthy();
expect(
within(row).getByText(
/Compatible avec Codex CLI 0\.145\.0 d'après le catalogue local IdeA\./,
),
).toBeTruthy();
fireEvent.change(model, { target: { value: "" } });
expect(within(row).getAllByText("Inconnu").length).toBeGreaterThan(0);
expect(within(row).getByText("Probablement trop récent")).toBeTruthy();
fireEvent.change(model, { target: { value: "future-codex-model" } });
expect(within(row).getByText("Inconnu")).toBeTruthy();
expect(
within(row).getByText(
/Compatibilité non connue pour Codex CLI 0\.145\.0 ; la saisie reste autorisée\./,
),
).toBeTruthy();
});
it("saves likely-too-recent models and shows a non-blocking warning", async () => {
const { profile } = renderSettings();
await waitReady();
await createProfile();
const row = screen.getAllByRole("listitem")[0];
const model = within(row).getByLabelText(/modele du profil/) as HTMLInputElement;
fireEvent.change(model, { target: { value: "gpt-5-mini" } });
fireEvent.click(within(row).getByRole("button", { name: "Enregistrer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.some((p) => p.model === "gpt-5-mini")).toBe(true);
});
expect(
await screen.findByText(/Ce modèle semble plus récent que votre Codex CLI 0\.145\.0/),
).toBeTruthy();
});
it("restores OpenCode local servers, local binding, and cloud provider choice in settings", async () => {
const modelServer = new MockModelServerGateway();
const server: LocalModelServerConfig = {
id: "550e8400-e29b-41d4-a716-446655440000",
kind: "llamaCpp",
name: "Local A",
baseURL: "http://localhost:8080/v1",
port: 8080,
servedModelName: "qwen3-coder-30b",
host: "127.0.0.1",
jinja: false,
args: [],
autoStart: false,
stopPolicy: "stopOnAppExit",
};
await modelServer.saveModelServer(server);
const { profile } = renderSettings(new MockProfileGateway(), modelServer);
await waitReady();
fireEvent.click(screen.getByRole("tab", { name: "OpenCode-local" }));
await waitReady();
expect(screen.getByRole("button", { name: "add model server" })).toBeTruthy();
expect(screen.getByText("Local A")).toBeTruthy();
await createProfile();
const select = await screen.findByLabelText(
"OpenCode + llama.cpp copy local model server",
);
fireEvent.change(select, { target: { value: server.id } });
const profileRow = select.closest("li");
expect(profileRow).not.toBeNull();
fireEvent.click(
within(profileRow as HTMLElement).getByRole("button", {
name: "Enregistrer",
}),
);
await waitFor(async () => {
const saved = await profile.listProfiles();
const opencode = saved.find((p) => p.structuredAdapter === "openCode");
expect(opencode?.opencode?.localModelServerId).toBe(server.id);
});
fireEvent.click(
within(profileRow as HTMLElement).getByRole("radio", {
name: "Provider cloud",
}),
);
expect(
await within(profileRow as HTMLElement).findByLabelText(
"OpenCode + llama.cpp copy provider",
),
).toBeTruthy();
});
});

View File

@ -1,34 +1,296 @@
/**
* Minimal "Settings AI Profiles" panel (L5). An always-available entry point
* to review the configured profiles and re-run the setup wizard after the first
* run. Kept intentionally small; richer per-profile editing reuses the wizard.
*
* Pure presentation over the {@link ProfileGateway} port (no `invoke()`).
* Settings -> AI Profiles. This is the durable CRUD surface for named runtime
* profiles; first-run stays a small default-profile bootstrap.
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { AgentProfile, GatewayError } from "@/domain";
import type {
AgentProfile,
GatewayError,
ModelCompatibility,
ProfileModelCatalog,
ProfileModelCatalogEntry,
} from "@/domain";
import { useGateways } from "@/app/di";
import { Button, Panel } from "@/shared";
import { FirstRunWizard } from "./FirstRunWizard";
import { Button, Input, Panel, cn } from "@/shared";
import {
ModelServersPanel,
useModelServers,
} from "@/features/model-servers";
import {
OpenCodeModeFields,
useOpenCodeProviderCatalog,
} from "./OpenCodeModeFields";
import { validateProfile } from "./profile";
type ProfileTab = "codex" | "claude" | "openCode";
type ModelTab = "codex" | "claude";
type CatalogState = Record<ModelTab, ProfileModelCatalog & { unavailable: boolean }>;
const TABS: Array<{ id: ProfileTab; label: string }> = [
{ id: "codex", label: "Codex" },
{ id: "claude", label: "Claude" },
{ id: "openCode", label: "OpenCode-local" },
];
const EMPTY_MODEL_CATALOG: ProfileModelCatalog & { unavailable: boolean } = {
models: [],
cliVersion: null,
warnings: [],
unavailable: false,
};
const EMPTY_CATALOGUE: CatalogState = {
codex: EMPTY_MODEL_CATALOG,
claude: EMPTY_MODEL_CATALOG,
};
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
function tabFor(profile: AgentProfile): ProfileTab | null {
if (profile.structuredAdapter === "codex") return "codex";
if (profile.structuredAdapter === "claude") return "claude";
if (profile.structuredAdapter === "openCode" && profile.opencode) {
return "openCode";
}
return null;
}
function modelOf(profile: AgentProfile): string {
if (profile.structuredAdapter === "openCode") {
return profile.opencode?.model ?? profile.opencodeProvider?.model ?? "";
}
return profile.model ?? "";
}
function withModel(profile: AgentProfile, model: string): AgentProfile {
const nextModel = model.trim() || undefined;
if (profile.structuredAdapter === "openCode" && profile.opencode) {
return {
...profile,
opencode: { ...profile.opencode, model: model.trim() },
};
}
return { ...profile, model: nextModel };
}
function optionLabel(entry: ProfileModelCatalogEntry): string {
return entry.recommended
? `${entry.displayName} (${entry.modelId}, recommande)`
: `${entry.displayName} (${entry.modelId})`;
}
function compatibilityLabel(compatibility: ModelCompatibility): string {
if (compatibility === "compatible") return "Compatible";
if (compatibility === "likelyTooRecent") return "Probablement trop récent";
return "Inconnu";
}
function engineLabel(tab: ModelTab): string {
return tab === "codex" ? "Codex CLI" : "Claude CLI";
}
function attentionBadge(
compatibility: ModelCompatibility,
cliVersion: string | null,
): string | null {
if (!cliVersion) return "CLI non détecté";
if (compatibility === "compatible") return null;
return compatibilityLabel(compatibility);
}
function catalogEntryFor(
model: string,
models: ProfileModelCatalogEntry[],
): ProfileModelCatalogEntry | null {
const normalized = model.trim().toLowerCase();
if (!normalized) return null;
return models.find((entry) => entry.modelId.toLowerCase() === normalized) ?? null;
}
function compatibilityFor(
model: string,
models: ProfileModelCatalogEntry[],
): ModelCompatibility {
return catalogEntryFor(model, models)?.compatibility ?? "unknown";
}
function modelHelp(
tab: ModelTab,
model: string,
catalog: ProfileModelCatalog & { unavailable: boolean },
): string {
const cli = engineLabel(tab);
if (catalog.unavailable) {
return "Catalogue provider indisponible ; vous pouvez saisir le modèle manuellement.";
}
if (!catalog.cliVersion) {
return `Version du ${cli} non détectée ; IdeA ne peut pas estimer la compatibilité.`;
}
const compatibility = compatibilityFor(model, catalog.models);
if (compatibility === "compatible") {
return `Compatible avec ${cli} ${catalog.cliVersion} d'après le catalogue local IdeA.`;
}
if (compatibility === "likelyTooRecent") {
return `Probablement trop récent pour ${cli} ${catalog.cliVersion} ; mettez à jour le CLI si le lancement échoue.`;
}
return `Compatibilité non connue pour ${cli} ${catalog.cliVersion} ; la saisie reste autorisée.`;
}
function saveWarningText(tab: ModelTab, cliVersion: string | null): string {
const cli = engineLabel(tab);
const version = cliVersion ? ` ${cliVersion}` : "";
return `Ce modèle semble plus récent que votre ${cli}${version}. Le profil peut être enregistré, mais l'agent pourrait échouer au lancement tant que le CLI n'est pas mis à jour.`;
}
function matchingSuggestions(
model: string,
models: ProfileModelCatalogEntry[],
): ProfileModelCatalogEntry[] {
const q = model.trim().toLowerCase();
const filtered = q
? models.filter((entry) =>
[entry.modelId, entry.displayName, ...entry.aliases]
.join(" ")
.toLowerCase()
.includes(q),
)
: models;
return filtered.slice(0, 5);
}
function ModelField({
profileId,
profileName,
tab,
model,
catalog,
onChange,
}: {
profileId: string;
profileName: string;
tab: ModelTab;
model: string;
catalog: ProfileModelCatalog & { unavailable: boolean };
onChange: (model: string) => void;
}) {
const [focused, setFocused] = useState(false);
const inputId = `profile-model-${tab}-${profileId}`;
const compatibility = compatibilityFor(model, catalog.models);
const badge = attentionBadge(compatibility, catalog.cliVersion);
const suggestions = matchingSuggestions(model, catalog.models);
return (
<div className="flex min-w-0 flex-col gap-1">
<label
htmlFor={inputId}
className="flex items-center gap-2 text-xs font-medium text-muted"
>
Modèle
<span
className="text-faint"
title="IdeA estime la compatibilité à partir du catalogue maintenu dans l'application et de la version du CLI détectée localement. Le provider peut accepter ou refuser le modèle différemment au moment du lancement."
>
compatibilité estimée
</span>
{badge && (
<span
className={cn(
"rounded-full px-2 py-0.5 text-[11px] font-medium",
compatibility === "likelyTooRecent"
? "bg-warning/15 text-warning"
: "bg-raised text-muted",
)}
>
{badge}
</span>
)}
</label>
<Input
id={inputId}
aria-label={`modele du profil ${profileName}`}
placeholder={
catalog.models.length > 0
? "Choisir ou saisir un modèle"
: "Saisir un modèle"
}
value={model}
onFocus={() => setFocused(true)}
onBlur={() => window.setTimeout(() => setFocused(false), 120)}
onChange={(e) => onChange(e.target.value)}
/>
<small className="text-xs text-faint">{modelHelp(tab, model, catalog)}</small>
{focused && suggestions.length > 0 && (
<div
role="listbox"
aria-label={`suggestions de modèles ${profileName}`}
className="mt-1 max-h-36 overflow-auto rounded-md border border-border bg-raised p-1"
>
{suggestions.map((entry) => (
<button
key={entry.modelId}
type="button"
role="option"
className="flex w-full items-center justify-between gap-3 rounded px-2 py-1.5 text-left text-xs hover:bg-surface"
onMouseDown={(e) => {
e.preventDefault();
onChange(entry.modelId);
setFocused(false);
}}
>
<span className="min-w-0 truncate">{optionLabel(entry)}</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium",
entry.compatibility === "compatible"
? "bg-primary/10 text-primary"
: entry.compatibility === "likelyTooRecent"
? "bg-warning/15 text-warning"
: "bg-surface text-muted",
)}
>
{compatibilityLabel(entry.compatibility)}
</span>
</button>
))}
</div>
)}
</div>
);
}
export function ProfilesSettings() {
const { profile } = useGateways();
const modelServers = useModelServers();
const providerCatalog = useOpenCodeProviderCatalog();
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
const [references, setReferences] = useState<AgentProfile[]>([]);
const [catalogue, setCatalogue] = useState(EMPTY_CATALOGUE);
const [activeTab, setActiveTab] = useState<ProfileTab>("codex");
const [drafts, setDrafts] = useState<Record<string, AgentProfile>>({});
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [catalogueWarning, setCatalogueWarning] = useState<string | null>(null);
const [saveWarnings, setSaveWarnings] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
setError(null);
try {
setProfiles(await profile.listProfiles());
const [saved, refs] = await Promise.all([
profile.listProfiles(),
profile.referenceProfiles(),
]);
setProfiles(saved);
setReferences(refs);
setDrafts(Object.fromEntries(saved.map((p) => [p.id, p])));
} catch (e) {
setError(
e && typeof e === "object" && "message" in e
? String((e as GatewayError).message)
: String(e),
);
setError(describe(e));
}
}, [profile]);
@ -36,22 +298,140 @@ export function ProfilesSettings() {
void refresh();
}, [refresh]);
async function del(id: string) {
await profile.deleteProfile(id);
await refresh();
useEffect(() => {
let cancelled = false;
async function loadCatalogue() {
setCatalogueWarning(null);
const [codex, claude] = await Promise.allSettled([
profile.listCodexModels(),
profile.listClaudeModels(),
]);
if (cancelled) return;
setCatalogue({
codex:
codex.status === "fulfilled"
? { ...codex.value, unavailable: false }
: { ...EMPTY_MODEL_CATALOG, unavailable: true },
claude:
claude.status === "fulfilled"
? { ...claude.value, unavailable: false }
: { ...EMPTY_MODEL_CATALOG, unavailable: true },
});
if (codex.status === "rejected" || claude.status === "rejected") {
setCatalogueWarning(
"Catalogue provider indisponible ; vous pouvez saisir le modèle manuellement.",
);
}
}
void loadCatalogue();
return () => {
cancelled = true;
};
}, [profile]);
const visibleProfiles = useMemo(
() => profiles.filter((p) => tabFor(p) === activeTab),
[profiles, activeTab],
);
const seed = useMemo(
() => references.find((p) => tabFor(p) === activeTab) ?? null,
[references, activeTab],
);
function updateDraft(id: string, updater: (profile: AgentProfile) => AgentProfile) {
setDrafts((prev) => {
const current = prev[id] ?? profiles.find((p) => p.id === id);
if (!current) return prev;
return { ...prev, [id]: updater(current) };
});
}
if (editing) {
// Reopened after the first run, so force the wizard to render.
return (
<FirstRunWizard
forceOpen
onDone={() => {
setEditing(false);
void refresh();
}}
/>
);
async function createFromSeed() {
if (!seed) return;
setBusy(true);
setError(null);
try {
const models =
activeTab === "codex" || activeTab === "claude"
? catalogue[activeTab].models
: [];
const recommended = models.find((m) => m.recommended)?.modelId;
await profile.cloneProfileFromSeed({
seedProfileId: seed.id,
name: `${seed.name} copy`,
model: recommended,
});
await refresh();
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}
async function save(id: string) {
const draft = drafts[id];
if (!draft) return;
setBusy(true);
setError(null);
setSaveWarnings((prev) => {
const { [id]: _ignored, ...rest } = prev;
return rest;
});
try {
const tab = tabFor(draft);
const warning =
tab === "codex" || tab === "claude"
? compatibilityFor(modelOf(draft), catalogue[tab].models) ===
"likelyTooRecent"
? saveWarningText(tab, catalogue[tab].cliVersion)
: null
: null;
await profile.saveProfile(draft);
await refresh();
if (warning) {
setSaveWarnings((prev) => ({ ...prev, [id]: warning }));
}
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}
async function duplicate(source: AgentProfile) {
setBusy(true);
setError(null);
try {
await profile.cloneProfileFromSeed({
seedProfileId: source.id,
name: `${source.name} copy`,
model:
source.structuredAdapter === "codex" ||
source.structuredAdapter === "claude"
? modelOf(source) || undefined
: undefined,
});
await refresh();
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}
async function del(source: AgentProfile) {
setBusy(true);
setError(null);
try {
await profile.deleteProfile(source.id);
await refresh();
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}
return (
@ -59,41 +439,154 @@ export function ProfilesSettings() {
aria-label="ai profiles settings"
title="Profils IA"
actions={
<Button size="sm" onClick={() => setEditing(true)}>
Configurer les profils
<Button size="sm" onClick={() => void createFromSeed()} disabled={!seed || busy}>
Creer un profil
</Button>
}
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-4">
<div
role="tablist"
aria-label="types de profils IA"
className="inline-flex w-fit rounded-md border border-border bg-raised p-0.5"
>
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"h-7 rounded px-3 text-xs font-medium transition-colors",
activeTab === tab.id
? "bg-surface text-content shadow-sm"
: "text-muted hover:text-content",
)}
>
{tab.label}
</button>
))}
</div>
{error && (
<p role="alert" className="text-sm text-danger">
{error}
</p>
)}
{catalogueWarning && (
<p className="text-xs text-muted">{catalogueWarning}</p>
)}
{profiles.length === 0 ? (
<p className="text-sm text-muted">Aucun profil configuré.</p>
{(activeTab === "codex" || activeTab === "claude") &&
catalogue[activeTab].warnings.map((warning) => (
<p key={warning} className="text-xs text-warning">
{warning}
</p>
))}
{activeTab === "openCode" && <ModelServersPanel vm={modelServers} />}
{visibleProfiles.length === 0 ? (
<p className="text-sm text-muted">
Aucun profil {TABS.find((tab) => tab.id === activeTab)?.label} configure.
</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{profiles.map((p) => (
<li
key={p.id}
className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0"
>
<span className="flex items-baseline gap-2">
<strong className="text-sm text-content">{p.name}</strong>
<code className="text-xs text-muted">{p.command}</code>
</span>
<Button
size="sm"
variant="ghost"
aria-label={`supprimer ${p.name}`}
onClick={() => void del(p.id)}
<ul className="flex flex-col gap-3">
{visibleProfiles.map((saved) => {
const draft = drafts[saved.id] ?? saved;
const model = modelOf(draft);
const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
const errors = validateProfile(draft);
return (
<li
key={saved.id}
className="rounded-md border border-border bg-surface p-3"
>
Supprimer
</Button>
</li>
))}
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<label className="flex min-w-0 flex-col gap-1">
<span className="text-xs font-medium text-muted">Nom</span>
<Input
aria-label={`nom du profil ${saved.name}`}
value={draft.name}
onChange={(e) =>
updateDraft(saved.id, (p) => ({
...p,
name: e.target.value,
}))
}
/>
</label>
{activeTab === "codex" || activeTab === "claude" ? (
<ModelField
profileId={saved.id}
profileName={saved.name}
tab={activeTab}
model={model}
catalog={catalogue[activeTab]}
onChange={(value) =>
updateDraft(saved.id, (p) => withModel(p, value))
}
/>
) : null}
</div>
{draft.structuredAdapter === "openCode" && (
<OpenCodeModeFields
profile={draft}
errors={errors}
servers={modelServers.servers}
providerCatalog={providerCatalog}
onChange={(next) =>
updateDraft(saved.id, () => next)
}
/>
)}
{saveWarnings[saved.id] && (
<p className="mt-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{saveWarnings[saved.id]}
</p>
)}
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<code className="min-w-0 truncate text-xs text-muted">
{draft.command}
{model ? ` · ${model}` : ""}
</code>
<span className="flex flex-wrap gap-1.5">
<Button
size="sm"
variant="primary"
disabled={!dirty || busy || draft.name.trim() === ""}
onClick={() => void save(saved.id)}
>
Enregistrer
</Button>
<Button
size="sm"
variant="secondary"
disabled={busy}
onClick={() => void duplicate(saved)}
>
Dupliquer
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy}
className="text-danger hover:text-danger"
aria-label={`supprimer ${saved.name}`}
onClick={() => void del(saved)}
>
Supprimer
</Button>
</span>
</div>
</li>
);
})}
</ul>
)}
</div>

View File

@ -59,8 +59,8 @@ describe("GitPanel (with MockGitGateway)", () => {
renderPanel(git);
await waitForPanel();
expect(screen.getByText("Staged")).toBeTruthy();
expect(screen.getByText("Unstaged")).toBeTruthy();
await screen.findByText("Staged");
await screen.findByText("Unstaged");
// src/main.rs is staged → Unstage button exists
expect(
screen.getByRole("button", { name: "unstage src/main.rs" }),

View File

@ -15,6 +15,7 @@ import { useEffect, useRef, useState } from "react";
import type {
LocalModelServerConfig,
ModelArtifact,
ModelServerCommandPreview,
ModelSource,
StopPolicy,
@ -47,6 +48,35 @@ const STOP_POLICIES: { value: StopPolicy; label: string }[] = [
{ value: "stopWhenUnused", label: "Stop when unused" },
];
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "";
const units = ["B", "KB", "MB", "GB", "TB"] as const;
let value = bytes;
let unit = 0;
while (value >= 1000 && unit < units.length - 1) {
value /= 1000;
unit += 1;
}
const digits = unit === 0 || value >= 10 ? 0 : 1;
return `${value.toFixed(digits)} ${units[unit]}`;
}
function deleteArtifactConfirmation(
server: LocalModelServerConfig,
artifact: Extract<ModelArtifact, { state: "downloaded" }>,
): string {
const size =
artifact.sizeBytes == null ? "" : `Espace libéré : ${formatBytes(artifact.sizeBytes)}.`;
return [
"Supprimer le modèle téléchargé ?",
`Serveur : ${server.name}`,
"Le serveur local restera configuré, mais IdeA devra retélécharger ce modèle au prochain lancement.",
size,
]
.filter(Boolean)
.join("\n");
}
export interface ModelServersPanelProps {
/** The model-server registry view-model (from `useModelServers`). */
vm: ModelServersViewModel;
@ -73,6 +103,14 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
if (saved) setDraft(null);
}
async function confirmDeleteArtifact(
server: LocalModelServerConfig,
artifact: Extract<ModelArtifact, { state: "downloaded" }>,
) {
if (!window.confirm(deleteArtifactConfirmation(server, artifact))) return;
await vm.deleteArtifact(server.id);
}
return (
<Panel
aria-label="local model servers"
@ -98,6 +136,11 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
{vm.error}
</p>
)}
{vm.notice && (
<p role="status" className="text-sm text-muted">
{vm.notice}
</p>
)}
{vm.servers.length === 0 && !draft && (
<p className="text-xs text-muted">
@ -107,40 +150,69 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
)}
<ul className="flex list-none flex-col gap-2 p-0">
{vm.servers.map((server) => (
<li
key={server.id}
className="flex items-center gap-2 rounded-md border border-border bg-raised p-2"
>
<span className="flex min-w-0 flex-col">
<strong className="truncate text-sm text-content">
{server.name}
</strong>
<span className="truncate text-xs text-muted">
{server.baseURL} · {server.servedModelName}
{server.autoStart ? " · auto-start" : ""}
{vm.servers.map((server) => {
const artifact = server.artifact;
const downloaded =
artifact?.state === "downloaded" ? artifact : undefined;
const deletingArtifact = vm.deletingArtifactId === server.id;
const downloadingArtifact = artifact?.state === "downloading";
return (
<li
key={server.id}
className="flex items-center gap-2 rounded-md border border-border bg-raised p-2"
>
<span className="flex min-w-0 flex-col">
<strong className="truncate text-sm text-content">
{server.name}
</strong>
<span className="truncate text-xs text-muted">
{server.baseURL} · {server.servedModelName}
{server.autoStart ? " · auto-start" : ""}
</span>
</span>
</span>
<span className="ml-auto flex items-center gap-1">
<Button
size="sm"
aria-label={`edit ${server.name}`}
onClick={() => startEdit(server)}
disabled={vm.busy}
>
Edit
</Button>
<IconButton
size="sm"
aria-label={`delete ${server.name}`}
onClick={() => void vm.remove(server.id)}
disabled={vm.busy}
>
×
</IconButton>
</span>
</li>
))}
<span className="ml-auto flex items-center gap-1">
{downloadingArtifact && (
<Button
size="sm"
variant="secondary"
aria-label={`download in progress ${server.name}`}
disabled
>
Téléchargement en cours
</Button>
)}
{downloaded && (
<Button
size="sm"
variant="danger"
aria-label={`delete downloaded model ${server.name}`}
onClick={() => void confirmDeleteArtifact(server, downloaded)}
loading={deletingArtifact}
disabled={vm.busy && !deletingArtifact}
>
{deletingArtifact ? "Suppression..." : "Supprimer le modèle téléchargé"}
</Button>
)}
<Button
size="sm"
aria-label={`edit ${server.name}`}
onClick={() => startEdit(server)}
disabled={vm.busy}
>
Edit
</Button>
<IconButton
size="sm"
aria-label={`delete ${server.name}`}
onClick={() => void vm.remove(server.id)}
disabled={vm.busy}
>
×
</IconButton>
</span>
</li>
);
})}
</ul>
{draft && (

View File

@ -6,7 +6,7 @@
* {@link ModelServerSelect} binding dropdown.
*/
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { act, render, renderHook, screen, fireEvent, waitFor } from "@testing-library/react";
import type { Gateways } from "@/ports";
@ -312,6 +312,95 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
});
it("deletes a downloaded managed model artifact after confirmation without deleting the server config", async () => {
const modelServer = new MockModelServerGateway();
await modelServer.saveModelServer({
...SERVER,
artifact: {
state: "downloaded",
path: "/cache/unsloth/Qwen3.5-9B-GGUF/model.gguf",
sizeBytes: 1_500_000_000,
},
});
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
renderPanel(modelServer);
fireEvent.click(await screen.findByLabelText("delete downloaded model Local A"));
await waitFor(() => {
expect(confirm).toHaveBeenCalledWith(expect.stringMatching(/1\.5 GB/));
});
await waitFor(async () => {
const [server] = await modelServer.listModelServers();
expect(server).toMatchObject({
id: SERVER.id,
artifact: { state: "missing" },
});
});
expect(await screen.findByText(/Modèle téléchargé supprimé/i)).toBeTruthy();
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull();
confirm.mockRestore();
});
it("does not offer model artifact deletion for local .gguf or missing managed artifacts", async () => {
const modelServer = new MockModelServerGateway();
await modelServer.saveModelServer({
...SERVER,
artifact: { state: "missing" },
});
await modelServer.saveModelServer({
...SERVER,
id: "550e8400-e29b-41d4-a716-446655440001",
name: "Local file",
modelSource: { type: "localPath", path: "/models/qwen.gguf" },
artifact: { state: "notManaged" },
});
renderPanel(modelServer);
await screen.findByLabelText("edit Local A");
expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull();
expect(screen.queryByLabelText("delete downloaded model Local file")).toBeNull();
});
it("shows a disabled downloading state instead of a delete action while an artifact is in progress", async () => {
const modelServer = new MockModelServerGateway();
await modelServer.saveModelServer({
...SERVER,
artifact: { state: "downloading" },
});
renderPanel(modelServer);
const downloading = await screen.findByLabelText("download in progress Local A");
expect((downloading as HTMLButtonElement).disabled).toBe(true);
expect(downloading.textContent).toContain("Téléchargement en cours");
expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull();
});
it("shows a short inline error when artifact deletion is blocked", async () => {
const modelServer = new MockModelServerGateway();
await modelServer.saveModelServer({
...SERVER,
artifact: {
state: "downloaded",
path: "/cache/model.gguf",
},
});
modelServer.markInUse(SERVER.id);
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
renderPanel(modelServer);
fireEvent.click(await screen.findByLabelText("delete downloaded model Local A"));
expect((await screen.findByRole("alert")).textContent).toMatch(
/téléchargement en cours ou agent actif/i,
);
expect(screen.getByLabelText("delete downloaded model Local A")).toBeTruthy();
confirm.mockRestore();
});
it("edits an existing server's served model name", async () => {
const modelServer = new MockModelServerGateway();
await modelServer.saveModelServer(SERVER);

View File

@ -24,14 +24,20 @@ export interface ModelServersViewModel {
servers: LocalModelServerConfig[];
/** Last error message, or `null`. */
error: string | null;
/** Last non-blocking success message, or `null`. */
notice: string | null;
/** Whether a request is in flight. */
busy: boolean;
/** Server id whose managed artifact is currently being deleted, or `null`. */
deletingArtifactId: string | null;
/** Reloads the server list. */
reload: () => Promise<void>;
/** Creates or updates a server; returns the persisted config (or `null` on error). */
save: (config: LocalModelServerConfig) => Promise<LocalModelServerConfig | null>;
/** Deletes a server by id; returns `true` on success. */
remove: (serverId: string) => Promise<boolean>;
/** Deletes only the managed downloaded model artifact; returns `true` on success. */
deleteArtifact: (serverId: string) => Promise<boolean>;
/**
* Asks the backend to build the `llama-server` command line for a draft
* (never reconstructed client-side). Returns `null` when the draft is
@ -62,11 +68,14 @@ export function useModelServers(): ModelServersViewModel {
const { modelServer } = useGateways();
const [servers, setServers] = useState<LocalModelServerConfig[]>([]);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [deletingArtifactId, setDeletingArtifactId] = useState<string | null>(null);
const reload = useCallback(async () => {
setBusy(true);
setError(null);
setNotice(null);
try {
setServers(await modelServer.listModelServers());
} catch (e) {
@ -84,6 +93,7 @@ export function useModelServers(): ModelServersViewModel {
async (config: LocalModelServerConfig) => {
setBusy(true);
setError(null);
setNotice(null);
try {
const saved = await modelServer.saveModelServer(config);
setServers((prev) => {
@ -110,6 +120,7 @@ export function useModelServers(): ModelServersViewModel {
async (serverId: string) => {
setBusy(true);
setError(null);
setNotice(null);
try {
await modelServer.deleteModelServer(serverId);
setServers((prev) => prev.filter((s) => s.id !== serverId));
@ -132,6 +143,37 @@ export function useModelServers(): ModelServersViewModel {
[modelServer],
);
const deleteArtifact = useCallback(
async (serverId: string) => {
setBusy(true);
setDeletingArtifactId(serverId);
setError(null);
setNotice(null);
try {
await modelServer.deleteModelArtifact(serverId);
setServers(await modelServer.listModelServers());
setNotice("Modèle téléchargé supprimé. Le serveur reste configuré.");
return true;
} catch (e) {
const code = codeOf(e);
if (code === "model_server_in_use") {
setError("Impossible de supprimer ce modèle : téléchargement en cours ou agent actif.");
} else if (code === "invalid") {
setError("Aucun modèle téléchargé géré à supprimer.");
} else if (code === "not_configured") {
setError("Serveur introuvable.");
} else {
setError(describe(e));
}
return false;
} finally {
setDeletingArtifactId(null);
setBusy(false);
}
},
[modelServer],
);
const preview = useCallback(
async (config: LocalModelServerConfig) => {
try {
@ -145,7 +187,22 @@ export function useModelServers(): ModelServersViewModel {
[modelServer],
);
const clearError = useCallback(() => setError(null), []);
const clearError = useCallback(() => {
setError(null);
setNotice(null);
}, []);
return { servers, error, busy, reload, save, remove, preview, clearError };
return {
servers,
error,
notice,
busy,
deletingArtifactId,
reload,
save,
remove,
deleteArtifact,
preview,
clearError,
};
}

View File

@ -90,10 +90,17 @@ function fixedConversation(): ConversationGateway {
};
}
function renderView(project: MockProjectGateway) {
const agent = new MockAgentGateway();
function renderView(
project: MockProjectGateway,
overrides: {
agent?: MockAgentGateway;
system?: MockSystemGateway;
} = {},
) {
const agent = overrides.agent ?? new MockAgentGateway();
const system = overrides.system ?? new MockSystemGateway();
const gateways = {
system: new MockSystemGateway(),
system,
project,
agent,
profile: new MockProfileGateway(),
@ -224,4 +231,75 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
screen.queryByRole("button", { name: "← Retour aux terminaux" }),
).toBeNull();
});
it("labels rendezvous completion toasts with requester and target, then opens the conversation", async () => {
const project = new MockProjectGateway();
const created = await project.createProject("alpha", "/p/a");
const agent = new MockAgentGateway();
const system = new MockSystemGateway();
const requester = await agent.createAgent(created.id, {
name: "Main",
profileId: "codex",
});
const target = await agent.createAgent(created.id, {
name: "DevBackend",
profileId: "codex",
});
renderView(project, { agent, system });
await openProjectAndWorkTab("/p/a");
system.emit({
type: "backgroundTaskChanged",
projectId: created.id,
agentId: target.id,
taskId: "task-rendezvous-91",
state: "completed",
requesterAgentId: requester.id,
targetAgentId: target.id,
conversationId: CONV_ID,
});
const toast = await screen.findByRole("button", {
name: /Main -> DevBackend completed/i,
});
expect(within(toast).getByText("Task task-ren")).toBeTruthy();
fireEvent.click(toast);
expect(
await screen.findByRole("button", { name: "← Retour aux terminaux" }),
).toBeTruthy();
expect(await screen.findByText("contenu du fil ouvert")).toBeTruthy();
expect(
screen.queryByRole("button", { name: /Main -> DevBackend completed/i }),
).toBeNull();
});
it("keeps the generic background-task toast when rendezvous agent ids are absent", async () => {
const project = new MockProjectGateway();
const created = await project.createProject("alpha", "/p/a");
const system = new MockSystemGateway();
renderView(project, { system });
await openProjectAndWorkTab("/p/a");
system.emit({
type: "backgroundTaskChanged",
projectId: created.id,
agentId: "agent-generic-1",
taskId: "task-generic-1",
state: "failed",
});
const toast = await screen.findByRole("button", {
name: /Background task failed/i,
});
expect(within(toast).getByText("agent-ge · task-gen")).toBeTruthy();
fireEvent.click(toast);
expect(
screen.queryByRole("button", { name: "← Retour aux terminaux" }),
).toBeNull();
expect(await screen.findByText("Work")).toBeTruthy();
});
});

View File

@ -35,7 +35,7 @@
import { useEffect, useState, type ReactNode } from "react";
import type { DomainEvent, LayoutInfo } from "@/domain";
import type { Agent, DomainEvent, LayoutInfo } from "@/domain";
import { LayoutGrid, LayoutTabs } from "@/features/layout";
import { ConversationViewer } from "@/features/conversations";
import {
@ -98,6 +98,14 @@ interface BackgroundTaskToast {
agentId: string;
taskId: string;
state: string;
title: string;
subtitle: string;
conversationId?: string;
}
interface PendingConversationOpen {
projectId: string;
conversationId: string;
}
function isTerminalBackgroundTaskEvent(
@ -112,9 +120,30 @@ function isTerminalBackgroundTaskEvent(
);
}
function shortTaskId(id: string): string {
return id.slice(0, 8);
}
function fallbackAgentLabel(id: string): string {
return shortTaskId(id);
}
function agentLabel(agents: Agent[], agentId: string): string {
return (
agents.find((candidate) => candidate.id === agentId)?.name ??
fallbackAgentLabel(agentId)
);
}
export function ProjectsView() {
const vm = useProjects();
const { system, window: windowGateway, focusedProject, git } = useGateways();
const {
system,
window: windowGateway,
focusedProject,
git,
agent,
} = useGateways();
const [name, setName] = useState("");
const [root, setRoot] = useState("");
// Placement of every open view (#22): each panel is "closed" (absent),
@ -142,6 +171,8 @@ export function ProjectsView() {
const [viewerConversationId, setViewerConversationId] = useState<
string | null
>(null);
const [pendingConversationOpen, setPendingConversationOpen] =
useState<PendingConversationOpen | null>(null);
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
@ -195,6 +226,14 @@ export function ProjectsView() {
setViewerConversationId(null);
}, [active?.id]);
useEffect(() => {
if (!active || pendingConversationOpen?.projectId !== active.id) return;
setSettingsSection(null);
setViewerConversationId(pendingConversationOpen.conversationId);
dismissFloating();
setPendingConversationOpen(null);
}, [active?.id, pendingConversationOpen]);
// Publish the focused project (#47) so detached panel-only windows follow the
// main window: they render this project, or an "open a project" shell when
// none is active. Publish on EVERY change of `active` — including
@ -211,17 +250,44 @@ export function ProjectsView() {
let cancelled = false;
void system.onDomainEvent((event) => {
if (!isTerminalBackgroundTaskEvent(event)) return;
const toast: BackgroundTaskToast = {
id: `${event.taskId}-${event.state}-${Date.now()}`,
projectId: event.projectId,
agentId: event.agentId,
taskId: event.taskId,
state: event.state,
};
setTaskToasts((prev) => [...prev.slice(-2), toast]);
window.setTimeout(() => {
setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id));
}, 7000);
void (async () => {
const hasRendezvousAgents = Boolean(
event.requesterAgentId && event.targetAgentId,
);
const labels = hasRendezvousAgents
? await agent
.listAgents(event.projectId)
.then((agents) => ({
requester: agentLabel(agents, event.requesterAgentId!),
target: agentLabel(agents, event.targetAgentId!),
}))
.catch(() => ({
requester: fallbackAgentLabel(event.requesterAgentId!),
target: fallbackAgentLabel(event.targetAgentId!),
}))
: null;
if (cancelled) return;
const toast: BackgroundTaskToast = {
id: `${event.taskId}-${event.state}-${Date.now()}`,
projectId: event.projectId,
agentId: event.agentId,
taskId: event.taskId,
state: event.state,
title: labels
? `${labels.requester} -> ${labels.target} ${event.state}`
: `Background task ${event.state}`,
subtitle: labels
? `Task ${shortTaskId(event.taskId)}`
: `${shortTaskId(event.agentId)} · ${shortTaskId(event.taskId)}`,
...(event.conversationId
? { conversationId: event.conversationId }
: {}),
};
setTaskToasts((prev) => [...prev.slice(-2), toast]);
window.setTimeout(() => {
setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id));
}, 7000);
})();
}).then((u) => {
if (cancelled) u();
else unsubscribe = u;
@ -230,7 +296,7 @@ export function ProjectsView() {
cancelled = true;
unsubscribe?.();
};
}, [system]);
}, [agent, system]);
const activeLayoutKind = activeLayout?.kind ?? "terminal";
@ -367,6 +433,29 @@ export function ProjectsView() {
dismissFloating();
}
async function handleTaskToastClick(toast: BackgroundTaskToast) {
setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id));
const projectOpen = vm.openTabs.some((tab) => tab.id === toast.projectId);
if (toast.conversationId) {
setPendingConversationOpen({
projectId: toast.projectId,
conversationId: toast.conversationId,
});
if (projectOpen) {
vm.activateTab(toast.projectId);
} else {
await vm.openProject(toast.projectId);
}
return;
}
if (projectOpen) vm.activateTab(toast.projectId);
setSettingsSection(null);
setViewerConversationId(null);
setPlacement("work", "floating");
}
// ── Menus (#26) ─────────────────────────────────────────────────────────
// A single « Panneaux » menu: one entry per panel, each opening a submenu with
// the placement actions directly (closed / floating / docked left/right /
@ -804,24 +893,13 @@ export function ProjectsView() {
key={toast.id}
type="button"
className="rounded-md border border-border bg-surface px-3 py-2 text-left shadow-lg hover:border-border-strong"
onClick={() => {
const projectOpen = vm.openTabs.some(
(tab) => tab.id === toast.projectId,
);
if (projectOpen) vm.activateTab(toast.projectId);
setSettingsSection(null);
setViewerConversationId(null);
setPlacement("work", "floating");
setTaskToasts((prev) =>
prev.filter((item) => item.id !== toast.id),
);
}}
onClick={() => void handleTaskToastClick(toast)}
>
<span className="block text-sm font-medium text-content">
Background task {toast.state}
{toast.title}
</span>
<span className="mt-0.5 block truncate text-xs text-muted">
{toast.agentId.slice(0, 8)} · {toast.taskId.slice(0, 8)}
{toast.subtitle}
</span>
</button>
))}

View File

@ -0,0 +1,67 @@
import { describe, it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
import type { Gateways } from "@/ports";
import { MockTerminalGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
const terminalOptions: unknown[] = [];
vi.mock("@xterm/xterm", () => ({
Terminal: class {
readonly rows = 24;
readonly cols = 80;
constructor(options: unknown) {
terminalOptions.push(options);
}
loadAddon() {}
open() {}
onData() {
return { dispose() {} };
}
write() {}
input() {}
focus() {}
dispose() {}
},
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
fit() {}
},
}));
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
import {
TerminalView,
TERMINAL_SCROLLBACK_LINES,
} from "./TerminalView";
describe("TerminalView scrollback", () => {
it("configures xterm with a deep scrollback for chatty OpenCode agents", async () => {
const gateways = { terminal: new MockTerminalGateway() } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<TerminalView cwd="/cwd" />
</DIProvider>,
);
expect(terminalOptions[0]).toMatchObject({
scrollback: TERMINAL_SCROLLBACK_LINES,
});
expect(TERMINAL_SCROLLBACK_LINES).toBeGreaterThan(1_000);
});
});

View File

@ -263,6 +263,13 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
globalThis.ResizeObserver = savedResizeObserver;
});
function setTerminalBoxSize(width: number, height: number) {
const container = screen.getByTestId("terminal-xterm-container");
Object.defineProperty(container, "clientWidth", { value: width, configurable: true });
Object.defineProperty(container, "clientHeight", { value: height, configurable: true });
return container;
}
it("sanity: with the polyfills xterm mounts and the opener runs", async () => {
// Guards the premise of the tests below: if this fails, the opener never
// fired and the error assertions would be vacuous.
@ -309,6 +316,48 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
expect(screen.queryByTestId("terminal-error")).toBeNull();
});
it("keeps a boot placeholder while the terminal box has no usable size", async () => {
const handle = makeHandle({ sessionId: "boot-zero-1" });
const open = vi.fn(async () => handle);
renderView(new MockTerminalGateway(), "/cwd", { open, refitSignal: 1 });
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
await new Promise((resolve) => requestAnimationFrame(resolve));
expect(screen.getByTestId("terminal-boot-placeholder")).toBeTruthy();
expect(screen.getByTestId("terminal-xterm-container").style.visibility).toBe(
"hidden",
);
expect(handle.resize).not.toHaveBeenCalled();
});
it("reveals xterm and resizes the handle after the first useful fit", async () => {
const handle = makeHandle({ sessionId: "boot-ready-1" });
const open = vi.fn(async () => handle);
const { rerender } = renderView(new MockTerminalGateway(), "/cwd", {
open,
refitSignal: 1,
});
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
expect(screen.getByTestId("terminal-boot-placeholder")).toBeTruthy();
setTerminalBoxSize(480, 240);
rerender(
<DIProvider gateways={{ terminal: new MockTerminalGateway() } as unknown as Gateways}>
<TerminalView cwd="/cwd" open={open} refitSignal={2} />
</DIProvider>,
);
await waitFor(() =>
expect(screen.queryByTestId("terminal-boot-placeholder")).toBeNull(),
);
expect(screen.getByTestId("terminal-xterm-container").style.visibility).toBe(
"visible",
);
await waitFor(() => expect(handle.resize).toHaveBeenCalled());
});
describe("refitSignal (ticket #61 — refit after split/merge)", () => {
it("refits WITHOUT reopening the terminal when refitSignal changes", async () => {
// Simulates LayoutGrid bumping `useLayout`'s layout version after a
@ -322,18 +371,14 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
refitSignal: 1,
});
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
const fitCallsAtMount = fitSpy.mock.calls.length;
expect(fitCallsAtMount).toBeGreaterThan(0);
fitSpy.mockClear();
// jsdom reports a zero-size layout box, which the coalesced refit
// deliberately skips (the same guard that protects the resize-observer
// path from fitting to a transient zero size). Stub a real size on the
// inner xterm container so the refit triggered below actually reaches
// `fit.fit()` instead of bailing on the zero-size guard.
const container = screen.getByTestId("terminal-view").firstElementChild as HTMLElement;
Object.defineProperty(container, "clientWidth", { value: 400, configurable: true });
Object.defineProperty(container, "clientHeight", { value: 200, configurable: true });
setTerminalBoxSize(400, 200);
rerender(
<DIProvider gateways={{ terminal: new MockTerminalGateway() } as unknown as Gateways}>
@ -341,9 +386,7 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
</DIProvider>,
);
await waitFor(() =>
expect(fitSpy.mock.calls.length).toBeGreaterThan(fitCallsAtMount),
);
await waitFor(() => expect(fitSpy).toHaveBeenCalled());
// The structural-mutation refit must never reopen the PTY.
expect(open).toHaveBeenCalledTimes(1);
@ -365,7 +408,7 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
fitSpy.mockClear();
const container = screen.getByTestId("terminal-view").firstElementChild as HTMLElement;
const container = screen.getByTestId("terminal-xterm-container");
// jsdom's default layout box is 0x0 — exactly the transient-zero case:
// left as-is, the container "hasn't settled" yet.
@ -391,6 +434,27 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
fitSpy.mockRestore();
});
it("refits after window restore/focus without a new refitSignal", async () => {
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
const open = vi.fn(async () => makeHandle({ sessionId: "restore-1" }));
renderView(new MockTerminalGateway(), "/cwd", {
open,
refitSignal: 1,
});
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
setTerminalBoxSize(400, 200);
await waitFor(() => expect(fitSpy).toHaveBeenCalled());
fitSpy.mockClear();
window.dispatchEvent(new Event("focus"));
await waitFor(() => expect(fitSpy).toHaveBeenCalled());
expect(open).toHaveBeenCalledTimes(1);
fitSpy.mockRestore();
});
it("does not refit when refitSignal is left undefined (no-op for callers that don't pass it)", async () => {
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
const open = vi.fn(async () => makeHandle({ sessionId: "no-signal-1" }));

View File

@ -47,6 +47,12 @@ import type {
WritePortal,
} from "@/ports";
// The backend PTY retains a bounded byte tail for reattach (~100 KB today), but
// xterm also has its own viewport history. Its default is too shallow for chatty
// OpenCode TUIs, which made the visible cell stop scrolling long before the
// retained terminal output was exhausted.
export const TERMINAL_SCROLLBACK_LINES = 10_000;
interface TerminalViewProps {
/** Working directory the shell opens in (typically the project root). */
cwd: string;
@ -157,6 +163,7 @@ export function TerminalView({
// buffer (which is invisible to assistive tech and absent when xterm can't
// mount). `null` ⇒ no error. The cell stays mounted and IdeA stays usable.
const [openError, setOpenError] = useState<string | null>(null);
const [terminalReady, setTerminalReady] = useState(false);
// The opener (`open` or the terminal gateway) is read through a ref so the
// effect does NOT depend on its identity. Otherwise every parent re-render
@ -184,7 +191,7 @@ export function TerminalView({
// Holds the mounted instance's `refit` closure so the `refitSignal` effect
// below (a separate effect, since it must NOT re-run/reopen the terminal on
// every parent render) can trigger it without depending on `cwd`'s effect.
const refitRef = useRef<(() => void) | null>(null);
const refitRef = useRef<((settleFrames?: number) => void) | null>(null);
useEffect(() => {
const container = containerRef.current;
@ -195,6 +202,7 @@ export function TerminalView({
// Fresh (re)mount: clear any prior failure banner before we try to open.
setOpenError(null);
setTerminalReady(false);
const term = new Terminal({
convertEol: false,
@ -202,6 +210,7 @@ export function TerminalView({
fontSize: 13,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
scrollback: TERMINAL_SCROLLBACK_LINES,
});
const fit = new FitAddon();
term.loadAddon(fit);
@ -213,15 +222,14 @@ export function TerminalView({
term.dispose();
return;
}
try {
fit.fit();
} catch {
/* container not laid out yet; a resize will retry */
}
let disposed = false;
let handle: TerminalHandle | null = null;
const encoder = new TextEncoder();
let rafId = 0;
let lastRows = term.rows;
let lastCols = term.cols;
let hasUsefulFit = false;
let settleFramesRemaining = 0;
// Keystroke → PTY path. The agent cell is a **native terminal**
// (ARCHITECTURE §20): keystrokes reach the PTY exactly like a plain shell.
@ -263,6 +271,11 @@ export function TerminalView({
// Adopt a freshly-established handle: flush buffered keystrokes. If the view
// was disposed before the promise resolved, just detach (NEVER close — the
// PTY must survive a transient mount/unmount).
const resizeHandleToCurrentGeometry = () => {
if (!handle) return;
if (term.rows <= 0 || term.cols <= 0) return;
void handle.resize(term.rows, term.cols);
};
const adopt = (h: TerminalHandle) => {
if (disposed) {
h.detach();
@ -276,6 +289,7 @@ export function TerminalView({
void h.write(encoder.encode(pending));
pending = "";
}
if (hasUsefulFit) resizeHandleToCurrentGeometry();
};
const onOpenError = (e: unknown) => {
@ -339,9 +353,6 @@ export function TerminalView({
// into a single `requestAnimationFrame` that runs after layout settles,
// (2) skip fitting while the container has no real size, and (3) push a PTY
// resize only when rows/cols actually change (avoids redundant reflows).
let rafId = 0;
let lastRows = term.rows;
let lastCols = term.cols;
// A refit can land on a transient 0x0 container (mount, or a structural
// layout mutation, before the box has actually settled). Previously this
// just gave up — fine on desktop, where a later window resize always
@ -350,6 +361,13 @@ export function TerminalView({
// now reschedules on the next few frames instead of abandoning — bounded,
// so a container that is genuinely never laid out (e.g. headless tests)
// doesn't spin forever.
// A successful fit can also land on a non-zero but still intermediate box
// during project/layout switches, split/merge commits, re-attach, and OS
// minimize/restore. Keep a small coalesced tail of fits on following frames
// so the final settled geometry is pushed automatically without requiring a
// manual resize. This stays bounded and preserves the rows/cols-changed
// guard before touching the PTY.
const SETTLE_REFIT_FRAMES = 4;
const MAX_ZERO_SIZE_RETRIES = 8;
let zeroSizeRetries = 0;
const refit = () => {
@ -368,18 +386,44 @@ export function TerminalView({
} catch {
return;
}
if (handle && (term.rows !== lastRows || term.cols !== lastCols)) {
if (term.rows <= 0 || term.cols <= 0) return;
const isFirstUsefulFit = !hasUsefulFit;
if (isFirstUsefulFit) {
hasUsefulFit = true;
setTerminalReady(true);
}
if (term.rows !== lastRows || term.cols !== lastCols) {
lastRows = term.rows;
lastCols = term.cols;
void handle.resize(term.rows, term.cols);
resizeHandleToCurrentGeometry();
} else if (isFirstUsefulFit) {
resizeHandleToCurrentGeometry();
}
if (settleFramesRemaining > 0) {
settleFramesRemaining -= 1;
rafId = requestAnimationFrame(refit);
}
};
const scheduleRefit = () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(refit);
const scheduleRefit = (settleFrames = 0) => {
settleFramesRemaining = Math.max(settleFramesRemaining, settleFrames);
if (!rafId) rafId = requestAnimationFrame(refit);
};
const ro = new ResizeObserver(scheduleRefit);
const scheduleSettledRefit = () => scheduleRefit(SETTLE_REFIT_FRAMES);
const scheduleVisibleRefit = () => {
if (document.visibilityState === "hidden") return;
scheduleSettledRefit();
};
const ro = new ResizeObserver(() => scheduleRefit());
ro.observe(container);
scheduleSettledRefit();
window.addEventListener("resize", scheduleSettledRefit);
window.addEventListener("focus", scheduleSettledRefit);
window.addEventListener("pageshow", scheduleSettledRefit);
document.addEventListener("visibilitychange", scheduleVisibleRefit);
window.visualViewport?.addEventListener("resize", scheduleSettledRefit);
// Let the `refitSignal` effect below trigger the SAME coalesced refit after
// a structural layout mutation (split/merge, ticket #61) — surviving cells
// don't always get a timely useful ResizeObserver event from a sibling
@ -391,6 +435,11 @@ export function TerminalView({
refitRef.current = null;
if (rafId) cancelAnimationFrame(rafId);
ro.disconnect();
window.removeEventListener("resize", scheduleSettledRefit);
window.removeEventListener("focus", scheduleSettledRefit);
window.removeEventListener("pageshow", scheduleSettledRefit);
document.removeEventListener("visibilitychange", scheduleVisibleRefit);
window.visualViewport?.removeEventListener("resize", scheduleSettledRefit);
onKey.dispose();
portalRef.current?.unbindHandle();
// DETACH, never close: tearing the view down (navigation / layout change)
@ -415,7 +464,7 @@ export function TerminalView({
// logic.
useEffect(() => {
if (refitSignal === undefined) return;
refitRef.current?.();
refitRef.current?.(4);
}, [refitSignal]);
const showNetworkBanner =
@ -439,7 +488,16 @@ export function TerminalView({
>
{/* xterm mounts into this inner node; the error banner is a sibling so
React never fights xterm over the same subtree. */}
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
<div
ref={containerRef}
data-testid="terminal-xterm-container"
aria-hidden={!terminalReady}
style={{
width: "100%",
height: "100%",
visibility: terminalReady ? "visible" : "hidden",
}}
/>
{showNetworkBanner && (
<div
role="status"
@ -468,6 +526,28 @@ export function TerminalView({
{networkReason}
</div>
)}
{!terminalReady && !openError && (
<div
data-testid="terminal-boot-placeholder"
aria-live="polite"
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem",
background: "#101214",
color: "#9ca3af",
fontSize: 13,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
zIndex: 1,
}}
>
Préparation du terminal
</div>
)}
{openError && (
<div
role="alert"

View File

@ -111,16 +111,14 @@ describe("WebAgentCell — refitSignal (ticket #61 web regression)", () => {
const { rerender } = renderCell(agent, seeded.id, { refitSignal: 1 });
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
const fitCallsAtMount = fitSpy.mock.calls.length;
expect(fitCallsAtMount).toBeGreaterThan(0);
fitSpy.mockClear();
// jsdom reports a zero-size layout box, which the coalesced refit
// deliberately skips — give the inner xterm container a real size so the
// refit below actually reaches `fit.fit()`.
const container = screen.getByTestId("web-agent-cell").querySelector(
'[data-testid="terminal-view"]',
)!.firstElementChild as HTMLElement;
'[data-testid="terminal-xterm-container"]',
) as HTMLElement;
Object.defineProperty(container, "clientWidth", { value: 400, configurable: true });
Object.defineProperty(container, "clientHeight", { value: 200, configurable: true });
@ -130,7 +128,7 @@ describe("WebAgentCell — refitSignal (ticket #61 web regression)", () => {
</DIProvider>,
);
await waitFor(() => expect(fitSpy.mock.calls.length).toBeGreaterThan(fitCallsAtMount));
await waitFor(() => expect(fitSpy).toHaveBeenCalled());
// The whole point: refit must never relaunch the agent's PTY.
expect(launchSpy).toHaveBeenCalledTimes(1);

View File

@ -384,8 +384,6 @@ describe("LiveProjectPanel — cellLayoutVersion (ticket #61 web regression)", (
const archiRow = screen.getByText("Archi").closest("li")!;
fireEvent.click(within(archiRow).getByRole("button", { name: "Ouvrir" }));
await screen.findByTestId("web-agent-cell");
// First cell mounted: at least the one guaranteed fit-on-mount happened.
expect(fitSpy.mock.calls.length).toBeGreaterThan(0);
// Switch to a different agent's cell — the panel shows one cell at a time,
// so this replaces (unmounts A, mounts B) rather than adding a second.
@ -398,8 +396,9 @@ describe("LiveProjectPanel — cellLayoutVersion (ticket #61 web regression)", (
// jsdom reports a zero-size layout box, which the refit deliberately skips
// — give the freshly-mounted cell's inner xterm container a real size so
// the refitSignal-driven refit actually reaches `fit.fit()`.
const containerB = cellB.querySelector('[data-testid="terminal-view"]')!
.firstElementChild as HTMLElement;
const containerB = cellB.querySelector(
'[data-testid="terminal-xterm-container"]',
) as HTMLElement;
Object.defineProperty(containerB, "clientWidth", { value: 400, configurable: true });
Object.defineProperty(containerB, "clientHeight", { value: 200, configurable: true });

View File

@ -180,6 +180,7 @@ describe("ProjectWorkStatePanel", () => {
// Mirrors AgentBackgroundTaskStateDto (crates/app-tauri/src/dto.rs): camelCase,
// `state` (not `status`), optional exitCode/summary/stdoutTail/stderrTail omitted
// when absent, createdAtMs/updatedAtMs present, NO ownerAgentId/projectId/finishedAtMs.
// Headless rendezvous tasks may carry requester/target/conversation context.
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(PROJECT_ID, {
agents: [
@ -200,6 +201,9 @@ describe("ProjectWorkStatePanel", () => {
taskId: "task-queued-1",
kind: "headlessRendezvous",
state: "queued",
requesterAgentId: "agent-main",
targetAgentId: "agent-bg",
conversationId: "conversation-rdv",
createdAtMs: 12,
updatedAtMs: 13,
},
@ -241,6 +245,9 @@ describe("ProjectWorkStatePanel", () => {
expect(tasks[2]?.stderrTail).toBe("boom");
// `summary` (ticket #5) is carried through when present.
expect(tasks[2]?.summary).toBe("process failed");
expect(tasks[1]?.requesterAgentId).toBe("agent-main");
expect(tasks[1]?.targetAgentId).toBe("agent-bg");
expect(tasks[1]?.conversationId).toBe("conversation-rdv");
// updatedAtMs (backend-emitted) is carried through for chronological ordering.
expect(tasks.map((t) => t.updatedAtMs)).toEqual([11, 13, 15, 17]);

View File

@ -37,6 +37,7 @@ import type {
McpToolPolicy,
OpenCodeConfig,
OpenCodeProviderCatalogEntry,
ProfileModelCatalog,
EffectivePermissions,
PairedDevice,
PairingCode,
@ -664,6 +665,15 @@ export interface ProfileGateway {
saveProfile(profile: AgentProfile): Promise<AgentProfile>;
/** Deletes a profile by id. */
deleteProfile(profileId: string): Promise<void>;
/**
* Clones a persisted or reference profile seed and saves the fresh profile.
* Used by Settings duplication for Codex/Claude/OpenCode identity copies.
*/
cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise<AgentProfile>;
/** Enriched Claude Code model catalogue. Manual model entry remains supported. */
listClaudeModels(): Promise<ProfileModelCatalog>;
/** Enriched Codex CLI model catalogue. Manual model entry remains supported. */
listCodexModels(): Promise<ProfileModelCatalog>;
/** Persists the batch of chosen profiles, closing the first run. */
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]>;
/**
@ -701,6 +711,16 @@ export interface CloneOpenCodeProfileFromSeedInput {
opencode?: OpenCodeConfig;
}
/** Input for {@link ProfileGateway.cloneProfileFromSeed}. */
export interface CloneProfileFromSeedInput {
/** Id of the persisted or reference profile to clone. */
seedProfileId: string;
/** Optional display name for the new profile. */
name?: string;
/** Optional model override. When omitted, the seed model is copied. */
model?: string;
}
/** Input for {@link ProfileGateway.saveOpenCodeProviderProfile}. */
export interface SaveOpenCodeProviderProfileInput {
/** The profile to create or replace (by id). */
@ -724,7 +744,7 @@ export interface SaveOpenCodeProviderProfileInput {
* `llama.cpp` servers an OpenCode profile can bind to via
* `OpenCodeConfig.localModelServerId`. Mirrors the backend commands
* (`list_model_servers`, `save_model_server`, `delete_model_server`,
* `preview_model_server_command`).
* `preview_model_server_command`, `delete_model_artifact`).
*/
export interface ModelServerGateway {
/** Lists the declared local model servers. */
@ -739,6 +759,13 @@ export interface ModelServerGateway {
* `model_server_in_use` when a profile still references it.
*/
deleteModelServer(serverId: string): Promise<void>;
/**
* Deletes the IdeA-managed downloaded model artifact for a server while
* keeping the server config. Rejects with `invalid` for local `.gguf` paths,
* `model_server_in_use` while downloading or used by a live agent, and
* `not_configured` when the server no longer exists.
*/
deleteModelArtifact(serverId: string): Promise<void>;
/**
* Builds the `llama-server` command line the backend would launch for the
* draft config, without persisting it. The backend is the sole authority on