--- name: ticket98-opencode-modelsdev-cache-seed description: memory note ticket98-opencode-modelsdev-cache-seed metadata: type: project --- --- slug: ticket98-opencode-modelsdev-cache-seed title: "Ticket #98 — Fix spawn OpenCode : seed du cache models.dev isolé (approche B2)" type: reference description: Cadrage figé du fix #98 (modèle écrasé par glm-5.2 au spawn pour provider catalogue cloud ex: zai) : cause racine = asymétrie picker↔spawn, approche B2 = semer best-effort le cache models.dev hôte dans le XDG_CACHE_HOME isolé. --- # Ticket #98 — Fix spawn OpenCode : seed du cache models.dev isolé ## Symptôme Wizard profil OpenCode first-run : provider catalogue cloud (ex: zai/ZAI Code) + modèle choisi → après sauvegarde l'agent tourne en `glm-5.2` quel que soit le modèle. Persistance correcte (`profiles.json` conserve `opencodeProvider.model`). `glm-5.2` est le **fallback interne d'OpenCode**, absent du code IdeA. ## Cause racine (confirmée + affinée par Architect) **Asymétrie picker↔spawn**, pas seulement « pas de bloc models » : 1. **Picker** (`provider_catalogue.rs:79-84,150-153`) : lit le cache models.dev via `opencode_models_cache_path()` qui résout le **vrai** cache hôte (`XDG_CACHE_HOME ?? ~/.cache`). Permet d'afficher zai + ses modèles. 2. **Spawn** (`lifecycle.rs:2386,2400-2407` ; `assistant/mod.rs:272,277-285`) : IdeA **ré-isole** `XDG_CACHE_HOME` → `.opencode/cache` **vide** sous `run_dir`. OpenCode ne voit plus le cache. 3. **Rendu** (`opencode_provider_config_json`, `lifecycle.rs:2795-2821` / `assistant/mod.rs:446-466`) : pour `config.custom == None` (défaut pour zai), IdeA n'émet que `apiKey` + `model: "zai/"`, **sans** bloc `models`. Correct **uniquement** pour les 3 built-ins hardcodés dans le binaire OpenCode (`anthropic`, `openai`, `openrouter`). → OpenCode reçoit `model: "zai/"`, ne reconnaît pas zai comme built-in, ne trouve pas le cache models.dev (isolé vide) → fallback silencieux glm-5.2. - **Local/custom marchent** : émettent un bloc `models` auto-suffisant (`assistant/mod.rs:364-381`, `:447-460`). - **3 built-ins marchent** : hardcodés dans OpenCode. - Bug ne touche **que** les providers connus d'OpenCode *exclusivement via models.dev*. ## Approche figée : **B2 — Semer le cache models.dev isolé depuis le cache hôte** Après création du `XDG_CACHE_HOME` isolé, copier **best-effort** le fichier cache models.dev hôte (`opencode_models_cache_path()` → `/opencode/models.json`) via le port `FileSystem`. Lecture hôte = `std::fs` (identique au picker) ; écriture dans la home isolée. **Pourquoi pas les autres :** - **(A)** Émettre un bloc `models`+`npm`+`baseURL` pour les catalogue → **écartée en v1** : IdeA devrait capturer le bon `npm` AI-SDK par provider depuis models.dev (`@ai-sdk/anthropic` vs `openai-compatible`…) — dupliquerait la connaissance du registry OpenCode (violation OCP), risque de régression built-ins. Gardée en **escalade** si B2 défait par refresh. - **(B1)** Pointer vers le vrai cache hôte (ne plus isoler) → **écartée** : casse l'isolation en écriture (pollution + race entre sessions). - **(C)** Bundler une copie statique models.dev dans IdeA → **écartée** : staleness + diverge picker/spawn. **Auto-cohérence B2** : la précondition du bug (cache hôte présent au picker) garantit la précondition du fix (fichier à copier au spawn). Cache hôte absent → picker ne montre que les 3 built-ins → bug non atteint → fix non requis. ## Contrat figé ### Ports / DTO - **Aucun nouveau port / entité domaine / DTO modifié.** Réutilise le port `FileSystem` déjà injecté sur le chemin spawn. Lecture source = `std::fs` hôte (mécanisme identique au picker). - Rendre **publique** `opencode_models_cache_path()` (source de vérité unique — encode le workaround du bug upstream #8235 ; ne **pas** re-dériver). ### Fichiers (périmètre DevBackend) - `crates/application/src/agent/provider_catalogue.rs` — exposer `opencode_models_cache_path()` en `pub` (ou ajouter `pub fn read_models_dev_cache_bytes() -> Option>`). - `crates/application/src/agent/mod.rs` — re-export. - `crates/application/src/agent/lifecycle.rs` — branche `apply_mcp_config` (≈2382-2408) : après `create_dir_all` du cache + création `xdg_cache`, semer `/opencode/models.json` depuis le cache hôte, best-effort. Branche **commune** opencode + opencodeProvider. - `crates/infrastructure/src/assistant/mod.rs` — branche miroir (≈268-285) : même appel. - **Factoriser** : `application::agent::seed_opencode_models_cache(fs: &dyn FileSystem, isolated_cache_dir: &str)` appelée par les deux sites. Partie **pure** testable `seed_from_bytes(fs, dest, src_bytes)` ; wrapper impur `std::fs::read` fin. ### Invariants (stricts) 1. Isolation préservée en **écriture** : HOME, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_CACHE_HOME restent sous `run_dir`. Aucune var d'env repointée vers l'hôte. Seul le **contenu** du cache isolé est semé (lecture seule). 2. **Best-effort, n'échoue jamais le launch** : source absente/illisible ou erreur FS → pas d'échec du spawn. Symétrique du contrat best-effort existant de `apply_mcp_config` (`lifecycle.rs:2422-2425`). ≠ `resolve_opencode_provider_api_key` (échec dur). Le seed est **mou**. 3. Pas de régression local/custom : `opencode_config_json` (llamacpp) et `opencode_provider_config_json(custom==Some)` **byte-identiques** (rendu non touché ; seed additif orthogonal). 4. Pas de régression built-ins : anthropic/openai/openrouter (`custom==None`, hardcodés) restent fonctionnels. 5. Exclusion mutuelle `opencode` vs `opencodeProvider` (#97) : non touchée. 6. Source de vérité unique du chemin models.dev : `opencode_models_cache_path()` réutilisée. 7. Cohérence picker↔spawn : modèles résolvables au spawn = sur-ensemble de ceux du picker (même fichier source). ### Hors périmètre (ne pas faire ici) - Résolution providers pour **projets remote (SSH/WSL)** (picker lit déjà le cache hôte — incohérence pré-existante). Fix corrige le cas **local**, ne régresse pas le remote. - Déduplication du rendu lifecycle↔infra (`opencode_provider_config_json` ×2) — on factorise **uniquement le seeder**. - Aucune modif UI/wizard. ## QA — 2 couches 1. **Unitaire `application`** (automatisable, déterministe) : - Partie pure `seed_from_bytes(fs, dest, src_bytes)` : bytes présents → assert `/opencode/models.json` écrit identique ; `None` → aucun fichier **et** pas d'erreur. - Non-régression rendu : JSON de `opencode_config_json` et `opencode_provider_config_json(custom==Some/None)` byte-identiques (tests existants `lifecycle.rs:4428+` verts). - ⇒ Ne prouve **pas** la résolution réelle par OpenCode. 2. **Intégration / réelle-exécution (gated, propriété QA)** : spawn réel d'OpenCode avec profil `zai` (`custom==None`) contre un cache models.dev fixture contenant `zai` ; assert **pas de fallback glm-5.2** (modèle `zai/` effectivement utilisé). Gater `#[ignore]`/env (clé API + réseau). **C'est ce test qui prouve le bug corrigé.** ## Risque résiduel + escalade - **Inconnu empirique** : est-ce qu'OpenCode au démarrage tente de **rafraîchir** models.dev (écrase notre copie, ou hang sans réseau) ? `OPENCODE_DISABLE_AUTOUPDATE=1` ne vise que l'auto-update du binaire, pas sûr qu'il couvre le refresh models. **QA doit vérifier** sur le test d'intégration. Si le refresh défait B2 → **escalader vers (A)** : capturer `npm`+`baseURL` réels par provider dans le parseur models.dev et peupler `custom`. ## Topologie - Branche : `feature/ticket98-opencode-modelsdev-cache-seed` depuis `develop@69e5878` (#97 exclusion mutuelle prérequis y est fusionné : `0f0a76d` + merge `5533073`). `crates/` propre. - Carnet #98 version 2 = source de vérité du cadrage. ## Lié - #97 (relatesTo) : exclusion mutuelle provider — prérequis livré.