From 045e0984cc289cee32ee41dbc67b445095b9d00c Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 28 Jul 2026 12:23:34 +0200 Subject: [PATCH] chantier: durcissement reprise headless inter-projets + persistance profils Opencode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implémentation reprise de session Opencode headless (conversational_recovery) - Persistance des profils IA Opencode dans .ideai/memory avec JSON Schema - Gestion des permissions MCP pour agents externes - Tests QA verts : structured_launch_d3, conversation_log, tickets_missing_carnet, agents, ProfilesSettings --- .ideai/mcp-tool-permissions.json | 4 +- .ideai/memory/MEMORY.md | 1 + ...les-opencode-persistence-list-coherence.md | 41 +++++ crates/app-tauri/src/tickets.rs | 49 ++++- .../app-tauri/tests/tickets_missing_carnet.rs | 98 ++++++++++ crates/application/src/agent/lifecycle.rs | 21 ++- .../application/tests/structured_launch_d3.rs | 174 ++++++++++++++++++ .../src/conversation_log/providers.rs | 55 ++++-- crates/infrastructure/src/session/factory.rs | 1 + crates/infrastructure/src/session/opencode.rs | 154 +++++++++++++++- .../infrastructure/tests/conversation_log.rs | 58 ++++++ crates/web-server/src/lib.rs | 67 ++++++- frontend/src/features/agents/agents.test.tsx | 15 ++ .../first-run/ProfilesSettings.test.tsx | 68 ++++++- .../features/first-run/ProfilesSettings.tsx | 25 ++- 15 files changed, 782 insertions(+), 49 deletions(-) create mode 100644 .ideai/memory/ux-ai-profiles-opencode-persistence-list-coherence.md create mode 100644 crates/app-tauri/tests/tickets_missing_carnet.rs diff --git a/.ideai/mcp-tool-permissions.json b/.ideai/mcp-tool-permissions.json index 7de1430..654ca78 100644 --- a/.ideai/mcp-tool-permissions.json +++ b/.ideai/mcp-tool-permissions.json @@ -21,7 +21,6 @@ "idea_context_propose", "idea_memory_write", "idea_workstate_set", - "idea_create_skill", "idea_ticket_create", "idea_ticket_update", "idea_ticket_update_status", @@ -31,7 +30,8 @@ "idea_ticket_unlink", "idea_template_create", "idea_template_update", - "idea_template_delete" + "idea_template_delete", + "idea_create_skill" ] }, "agents": [ diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index e4d95b9..ceb8c19 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -73,3 +73,4 @@ - [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 +- [ux-ai-profiles-opencode-persistence-list-coherence](ux-ai-profiles-opencode-persistence-list-coherence.md) — memory note ux-ai-profiles-opencode-persistence-list-coherence diff --git a/.ideai/memory/ux-ai-profiles-opencode-persistence-list-coherence.md b/.ideai/memory/ux-ai-profiles-opencode-persistence-list-coherence.md new file mode 100644 index 0000000..688c9e6 --- /dev/null +++ b/.ideai/memory/ux-ai-profiles-opencode-persistence-list-coherence.md @@ -0,0 +1,41 @@ +--- +name: ux-ai-profiles-opencode-persistence-list-coherence +description: memory note ux-ai-profiles-opencode-persistence-list-coherence +metadata: + type: project +--- +--- +title: UX — Cohérence profils IA/OpenCode entre éditeur et picker agents +type: decision +description: Décisions de surface pour corriger la création de profils OpenCode, la persistance visible après sauvegarde et la cohérence entre Settings > Profils IA et les sélecteurs d'agents. +--- + +# UX — Profils IA/OpenCode : création, persistance, liste unique + +## Décision centrale +La liste visible dans `Settings > Profils IA` et les pickers de profils des agents doit représenter la même collection persistée de profils IA. Les profils de référence ne sont qu'une source d'ajout, pas une deuxième vérité visible. + +## Comportement attendu +- Le bouton de création OpenCode s'appelle `Créer un profil OpenCode` dans la surface française. +- Cliquer sur ce bouton ajoute immédiatement une ligne de profil en brouillon, sélectionnée, éditable et clairement marquée non enregistrée tant que la sauvegarde n'a pas réussi. +- Chaque nouveau profil OpenCode doit avoir une identité distincte et un nom humain distinct par défaut, par exemple `OpenCode local 2`; l'utilisateur peut renommer avant sauvegarde. +- `Dupliquer` conserve la configuration du profil source mais crée une nouvelle identité et un nom suffixé, par exemple `(copie)`. +- `Enregistrer` persiste tous les profils sélectionnés/édités, puis recharge la liste depuis la source persistée. La liste affichée après succès doit être le résultat relu, pas seulement l'état local optimiste. +- Après sauvegarde réussie, le profil créé reste visible dans l'éditeur sans réouverture manuelle et apparaît dans le picker de création d'agent et dans le picker de changement de profil d'un agent existant. +- Les profils OpenCode ne doivent jamais être fusionnés/dédupliqués par commande, modèle, endpoint ou adapter. La déduplication visible se fait uniquement par `id`. +- En mode édition, les profils déjà configurés apparaissent en premier, pré-sélectionnés, avec leur configuration réelle intacte. Les profils de référence non configurés peuvent suivre comme suggestions non sélectionnées. +- Si un profil référencé par un agent n'existe plus dans la liste persistée, le picker de l'agent conserve l'id courant comme option orpheline lisible, sans le mélanger aux profils disponibles. + +## États et feedback +- Pendant la création/clonage : bouton désactivé ou loading local, sans bloquer la détection CLI. +- Ligne brouillon : indicateur discret `Non enregistré` ou état visuel équivalent. +- Sauvegarde réussie : statut court `Profils enregistrés` puis disparition automatique. +- Échec sauvegarde : message d'erreur persistant, la ligne brouillon reste éditable et n'est pas perdue. +- Picker agents vide : ne pas encourager la saisie libre d'id comme chemin normal. Afficher plutôt un état vide actionnable vers `Profils IA`. + +## Critères d'acceptation visuels +- Créer un profil OpenCode, sauvegarder, rester sur Settings : le profil est toujours visible après le retour succès. +- Sans fermer Settings, ouvrir/créer un agent : le même profil est disponible dans le picker. +- Deux profils OpenCode avec même endpoint/modèle mais ids distincts restent deux lignes et deux options distinctes. +- Renommer un profil puis sauvegarder met à jour le libellé dans l'éditeur et dans les pickers agents. +- Aucun écran ne montre simultanément une liste issue seulement des références et une liste issue des profils persistés comme si elles étaient équivalentes. \ No newline at end of file diff --git a/crates/app-tauri/src/tickets.rs b/crates/app-tauri/src/tickets.rs index f60c3b7..57292d2 100644 --- a/crates/app-tauri/src/tickets.rs +++ b/crates/app-tauri/src/tickets.rs @@ -72,8 +72,18 @@ impl TicketToolProvider for AppTicketToolProvider { .await .map_err(ticket_error)? .issue; + let fallback_carnet = issue.carnet.as_str().to_owned(); let carnet = if include_carnet { - Some(read_carnet_body(&*self.read_carnet, project, issue_ref).await?) + Some( + read_ticket_carnet_body_best_effort( + &*self.read_carnet, + project, + issue_ref, + fallback_carnet, + ) + .await + .map_err(ticket_error)?, + ) } else { None }; @@ -287,17 +297,17 @@ pub async fn ticket_read( .await .map_err(ErrorDto::from)? .issue; + let fallback_carnet = issue.carnet.as_str().to_owned(); let carnet = if request.include_carnet.unwrap_or(false) { Some( - state - .read_issue_carnet - .execute(ReadIssueCarnetInput { project, issue_ref }) - .await - .map_err(ErrorDto::from)? - .carnet - .carnet - .as_str() - .to_owned(), + read_ticket_carnet_body_best_effort( + &state.read_issue_carnet, + &project, + issue_ref, + fallback_carnet, + ) + .await + .map_err(ErrorDto::from)?, ) } else { None @@ -680,6 +690,25 @@ async fn read_carnet_body( .map_err(ticket_error) } +async fn read_ticket_carnet_body_best_effort( + read_carnet: &application::ReadIssueCarnet, + project: &Project, + issue_ref: IssueRef, + fallback_carnet: String, +) -> Result { + match read_carnet + .execute(ReadIssueCarnetInput { + project: project.clone(), + issue_ref, + }) + .await + { + Ok(out) => Ok(out.carnet.carnet.as_str().to_owned()), + Err(AppError::NotFound(_)) => Ok(fallback_carnet), + Err(err) => Err(err), + } +} + fn ticket_error(err: AppError) -> TicketToolError { let message = err.to_string(); let code = if message.contains("issue version conflict") { diff --git a/crates/app-tauri/tests/tickets_missing_carnet.rs b/crates/app-tauri/tests/tickets_missing_carnet.rs new file mode 100644 index 0000000..d90a16d --- /dev/null +++ b/crates/app-tauri/tests/tickets_missing_carnet.rs @@ -0,0 +1,98 @@ +use std::path::PathBuf; + +use app_tauri_lib::state::AppState; +use application::CreateProjectInput; +use infrastructure::TicketToolProvider; +use serde_json::json; +use uuid::Uuid; + +fn temp_path(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!("idea-app-tauri-test-{tag}-{}", Uuid::new_v4())) +} + +#[tokio::test] +async fn idea_ticket_read_is_best_effort_but_read_carnet_stays_strict_when_carnet_file_is_missing() +{ + let state = AppState::build(temp_path("appdata")); + let project_root = temp_path("project").to_string_lossy().into_owned(); + let project = state + .create_project + .execute(CreateProjectInput { + name: "App Tauri Tickets".to_owned(), + root: project_root, + remote: None, + default_profile_id: None, + }) + .await + .expect("test project is created") + .project; + + let created = state + .ticket_tool_binder + .handle_ticket_tool( + &project, + "qa-agent", + "idea_ticket_create", + json!({ + "title": "Missing carnet fallback", + "description": "Created by app-tauri integration test", + "priority": "high", + "status": "open" + }), + ) + .await + .expect("ticket create succeeds"); + let ticket_ref = created["ref"] + .as_str() + .expect("ticket ref is present") + .to_owned(); + + let read = state + .ticket_tool_binder + .handle_ticket_tool( + &project, + "qa-agent", + "idea_ticket_read", + json!({ "ref": ticket_ref, "includeCarnet": true }), + ) + .await + .expect("ticket read succeeds"); + assert!( + read["carnet"].is_string(), + "ticket read returns a string carnet" + ); + + std::fs::remove_file( + PathBuf::from(project.root.as_str()) + .join(".ideai") + .join("tickets") + .join(ticket_ref.trim_start_matches('#')) + .join("carnet.md"), + ) + .expect("test removes ticket carnet"); + + let read_without_carnet_file = state + .ticket_tool_binder + .handle_ticket_tool( + &project, + "qa-agent", + "idea_ticket_read", + json!({ "ref": ticket_ref, "includeCarnet": true }), + ) + .await + .expect("ticket read stays best-effort"); + assert_eq!(read_without_carnet_file["ref"], ticket_ref); + assert_eq!(read_without_carnet_file["carnet"], ""); + + let strict_err = state + .ticket_tool_binder + .handle_ticket_tool( + &project, + "qa-agent", + "idea_ticket_read_carnet", + json!({ "ref": ticket_ref }), + ) + .await + .expect_err("strict carnet read must fail when carnet.md is missing"); + assert_eq!(strict_err.code, "notFound"); +} diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 9bf23b1..0d58a68 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -2025,7 +2025,11 @@ impl LaunchAgent { // même entrée. Toute défaillance (provider absent, pair_conversation_id non-UUID, // erreur du store) dégrade silencieusement — un lancement n'est **jamais** cassé // par cette persistance (lot P8c lira ce store pour `--resume`). - if let Some(engine_id) = engine_session_id.as_deref() { + if let Some(engine_id) = engine_session_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + { self.persist_provider_session(root, profile, &pair_conversation_id, engine_id) .await; } @@ -2127,11 +2131,20 @@ impl LaunchAgent { // L'id de paire (UUID) → resumable moteur via providers.json. Some(raw) => match uuid::Uuid::parse_str(raw) { Ok(uuid) => match provider.provider_session_store_for(root) { - Some(store) => store + Some(store) => match store .get(ConversationId::from_uuid(uuid), adapter.provider_key()) .await - .ok() - .flatten(), + { + Ok(Some(engine_id)) => { + let engine_id = engine_id.trim().to_owned(); + if engine_id.is_empty() { + None + } else { + Some(engine_id) + } + } + Ok(None) | Err(_) => None, + }, None => None, }, Err(_) => None, diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index 4658a48..3abafb8 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -566,6 +566,7 @@ use domain::{ConversationId, ConversationParty, ProviderSessionStore}; struct FakeProviderSessionStore { entries: Arc>>, fail_set: bool, + fail_get: bool, } impl FakeProviderSessionStore { @@ -577,6 +578,15 @@ impl FakeProviderSessionStore { Self { entries: Arc::new(Mutex::new(HashMap::new())), fail_set: true, + fail_get: false, + } + } + /// A store whose `get` always errors (corrupted/unreadable providers.json). + fn failing_get() -> Self { + Self { + entries: Arc::new(Mutex::new(HashMap::new())), + fail_set: false, + fail_get: true, } } /// Pre-seeds a `(conversation, provider) → resumable_id` mapping synchronously, @@ -608,6 +618,11 @@ impl ProviderSessionStore for FakeProviderSessionStore { conversation: ConversationId, provider_id: &str, ) -> Result, StoreError> { + if self.fail_get { + return Err(StoreError::Serialization( + "forced providers.json corruption".to_owned(), + )); + } Ok(self.get_sync(conversation, provider_id)) } async fn set( @@ -641,6 +656,31 @@ impl ProviderSessionProvider for FakeProviderSessionProvider { } } +#[derive(Clone, Default)] +struct RootAwareProviderSessionProvider { + stores: Arc>>>, +} + +impl RootAwareProviderSessionProvider { + fn insert(&self, root: &str, store: Arc) { + self.stores.lock().unwrap().insert(root.to_owned(), store); + } +} + +impl ProviderSessionProvider for RootAwareProviderSessionProvider { + fn provider_session_store_for( + &self, + root: &ProjectPath, + ) -> Option> { + self.stores + .lock() + .unwrap() + .get(root.as_str()) + .cloned() + .map(|store| store as Arc) + } +} + // --------------------------------------------------------------------------- // Builders // --------------------------------------------------------------------------- @@ -675,6 +715,17 @@ fn project() -> Project { .unwrap() } +fn project_with_root(root: &str) -> Project { + Project::new( + project_id(), + "demo", + ProjectPath::new(root).unwrap(), + RemoteRef::local(), + 1_700_000_000_000, + ) + .unwrap() +} + /// Profil **structuré** (porte un `structured_adapter`), convention file CLAUDE.md. fn structured_profile(id: ProfileId) -> AgentProfile { AgentProfile::new( @@ -1398,6 +1449,22 @@ fn structured_codex_profile(id: ProfileId) -> AgentProfile { .with_structured_adapter(StructuredAdapter::Codex) } +/// Profil **structuré OpenCode** pour prouver la clé provider `"opencode"`. +fn structured_opencode_profile(id: ProfileId) -> AgentProfile { + AgentProfile::new( + id, + "OpenCode Structuré", + "opencode", + Vec::new(), + ContextInjection::convention_file("AGENTS.md").unwrap(), + Some("opencode --version".to_owned()), + "{agentRunDir}", + None, + ) + .unwrap() + .with_structured_adapter(StructuredAdapter::OpenCode) +} + /// Builds a `LaunchAgent.with_structured(...).with_provider_session_provider(...)` /// over the given profile/factory, returning the launcher, the agent and the /// observable provider store. When `provider` is `None`, no provider is wired @@ -1504,6 +1571,7 @@ async fn p8b_nominal_codex_writes_engine_id_under_codex_key() { fn p8b_provider_key_mapping_is_stable() { assert_eq!(StructuredAdapter::Claude.provider_key(), "claude"); assert_eq!(StructuredAdapter::Codex.provider_key(), "codex"); + assert_eq!(StructuredAdapter::OpenCode.provider_key(), "opencode"); } /// **No-op sans provider** : sans `with_provider_session_provider`, le lancement @@ -1615,6 +1683,39 @@ fn launch_fixture_p8c( (launch, agent, observed) } +fn launch_fixture_with_provider( + profile: AgentProfile, + factory: FakeFactory, + provider: Arc, +) -> (Arc, Agent, FakeFactory) { + let observed = factory.clone(); + let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id); + let contexts = FakeContexts::with_agent(&agent, "# ctx body"); + let profiles = FakeProfiles::new(vec![profile]); + let runtime = FakeRuntime::new(); + let fs = FakeFs::default(); + let pty = FakePty::new(sid(777)); + let sessions = Arc::new(TerminalSessions::new()); + let structured = Arc::new(StructuredSessions::new()); + let bus = SpyBus::default(); + let launch = LaunchAgent::new( + Arc::new(contexts), + Arc::new(profiles), + Arc::new(runtime), + Arc::new(fs), + Arc::new(pty), + Arc::new(FakeSkills), + Arc::clone(&sessions), + Arc::new(bus), + Arc::new(SeqIds::new()), + Arc::new(FakeRecall), + None, + ) + .with_structured(Arc::new(factory), Arc::clone(&structured)) + .with_provider_session_provider(provider); + (Arc::new(launch), agent, observed) +} + /// **Cas 1 — Claude câblé, store contient l'engine id** : la cellule porte l'id de /// paire (UUID) ; le store mappe `(pair, "claude") → "engine-x"`. Le lancement doit /// transmettre `SessionPlan::Resume{ conversation_id: "engine-x" }` à la factory — @@ -1812,3 +1913,76 @@ async fn p8c_non_uuid_pair_id_with_store_resolves_to_none() { "a non-UUID pair id must degrade to None, never resume the raw string" ); } + +#[tokio::test] +async fn p8c_store_get_error_fails_closed_to_fresh_session() { + let store = Arc::new(FakeProviderSessionStore::failing_get()); + let factory = FakeFactory::new(500, None); + let (launch, agent, observed) = launch_fixture_p8c( + structured_opencode_profile(pid(9)), + factory, + Some(Arc::clone(&store)), + ); + + let pair = ConversationId::from_uuid(Uuid::from_u128(123)); + let mut input = launch_input(agent.id); + input.conversation_id = Some(pair.to_string()); + + launch.execute(input).await.expect("structured launch"); + + let starts = observed.starts(); + assert_eq!(starts.len(), 1); + assert_eq!( + starts[0].1, + SessionPlan::None, + "corrupted provider session store must start fresh, never resume ambiguously" + ); +} + +#[tokio::test] +async fn p8c_opencode_same_pair_and_provider_are_isolated_by_project_root() { + let store_a = Arc::new(FakeProviderSessionStore::new()); + let store_b = Arc::new(FakeProviderSessionStore::new()); + let provider = RootAwareProviderSessionProvider::default(); + provider.insert("/home/me/project-a", Arc::clone(&store_a)); + provider.insert("/home/me/project-b", Arc::clone(&store_b)); + let provider = Arc::new(provider) as Arc; + + let factory = FakeFactory::new(500, None); + let (launch_a, agent_a, observed) = launch_fixture_with_provider( + structured_opencode_profile(pid(9)), + factory.clone(), + Arc::clone(&provider), + ); + let (launch_b, agent_b, _) = + launch_fixture_with_provider(structured_opencode_profile(pid(9)), factory, provider); + let pair = ConversationId::from_uuid(Uuid::from_u128(123)); + store_a.seed_sync(pair, "opencode", "engine-project-a"); + store_b.seed_sync(pair, "opencode", "engine-project-b"); + + let mut input_a = launch_input(agent_a.id); + input_a.project = project_with_root("/home/me/project-a"); + input_a.conversation_id = Some(pair.to_string()); + launch_a.execute(input_a).await.expect("project A launch"); + + let mut input_b = launch_input(agent_b.id); + input_b.project = project_with_root("/home/me/project-b"); + input_b.conversation_id = Some(pair.to_string()); + launch_b.execute(input_b).await.expect("project B launch"); + + let starts = observed.starts(); + assert_eq!(starts.len(), 2); + assert_eq!( + starts[0].1, + SessionPlan::Resume { + conversation_id: "engine-project-a".to_owned() + } + ); + assert_eq!( + starts[1].1, + SessionPlan::Resume { + conversation_id: "engine-project-b".to_owned() + }, + "project B must read its own opencode token, never project A's" + ); +} diff --git a/crates/infrastructure/src/conversation_log/providers.rs b/crates/infrastructure/src/conversation_log/providers.rs index 6aa8a2d..b5bf31b 100644 --- a/crates/infrastructure/src/conversation_log/providers.rs +++ b/crates/infrastructure/src/conversation_log/providers.rs @@ -62,8 +62,11 @@ type ProviderMap = HashMap; /// le **project root** est fourni au constructeur, la base `/.ideai/conversations` /// en dérive, et chaque conversation a son sous-dossier `/`. pub struct FsProviderSessionStore { + /// Racine projet canonique. `None` si le root fourni n'est pas résoluble : + /// le store devient alors fail-closed (lecture `None`, écriture `Err`). + root: Option, /// Racine `/.ideai/conversations`. - base: PathBuf, + base: Option, /// Verrous d'écriture, un par fichier de conversation (sérialise le read-modify-write). write_locks: Mutex>>>, } @@ -75,28 +78,48 @@ impl FsProviderSessionStore { /// est créé paresseusement au premier `set`. #[must_use] pub fn new(root: &ProjectPath) -> Self { - let base = PathBuf::from(root.as_str()) - .join(IDEAI_DIR) - .join(CONVERSATIONS_DIR); + let root = std::fs::canonicalize(root.as_str()).ok(); + let base = root + .as_ref() + .map(|root| root.join(IDEAI_DIR).join(CONVERSATIONS_DIR)); Self { + root, base, write_locks: Mutex::new(HashMap::new()), } } /// `/` — le dossier d'une conversation. - fn conversation_dir(&self, conversation: ConversationId) -> PathBuf { - self.base.join(conversation.to_string()) + fn conversation_dir(&self, conversation: ConversationId) -> Result { + let Some(root) = self.root.as_ref() else { + return Err(StoreError::Io( + "project root canonique indisponible pour providers.json".to_owned(), + )); + }; + let Some(base) = self.base.as_ref() else { + return Err(StoreError::Io( + "base providers.json indisponible".to_owned(), + )); + }; + let dir = base.join(conversation.to_string()); + if !dir.starts_with(root) { + return Err(StoreError::Io( + "chemin providers.json hors du project root canonique".to_owned(), + )); + } + Ok(dir) } /// `//providers.json` — le fichier des sessions par provider. - fn providers_path(&self, conversation: ConversationId) -> PathBuf { - self.conversation_dir(conversation).join(PROVIDERS_FILE) + fn providers_path(&self, conversation: ConversationId) -> Result { + Ok(self.conversation_dir(conversation)?.join(PROVIDERS_FILE)) } /// `//providers.json.tmp` — le fichier temporaire d'écriture. - fn providers_tmp_path(&self, conversation: ConversationId) -> PathBuf { - self.conversation_dir(conversation).join(PROVIDERS_TMP_FILE) + fn providers_tmp_path(&self, conversation: ConversationId) -> Result { + Ok(self + .conversation_dir(conversation)? + .join(PROVIDERS_TMP_FILE)) } /// Renvoie (en le créant au besoin) le verrou d'écriture de `conversation`. @@ -114,7 +137,11 @@ impl FsProviderSessionStore { /// Fichier absent ⇒ map vide (jamais une erreur). JSON illisible ⇒ /// [`StoreError::Serialization`]. async fn load_map(&self, conversation: ConversationId) -> Result { - let bytes = match tokio::fs::read(self.providers_path(conversation)).await { + let path = match self.providers_path(conversation) { + Ok(path) => path, + Err(_) => return Ok(ProviderMap::new()), + }; + let bytes = match tokio::fs::read(path).await { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ProviderMap::new()), Err(e) => return Err(StoreError::Io(e.to_string())), @@ -155,18 +182,18 @@ impl ProviderSessionStore for FsProviderSessionStore { let body = serde_json::to_vec(&map).map_err(|e| StoreError::Serialization(e.to_string()))?; - let dir = self.conversation_dir(conversation); + let dir = self.conversation_dir(conversation)?; tokio::fs::create_dir_all(&dir) .await .map_err(|e| StoreError::Io(e.to_string()))?; // Écriture atomique : écrire le tmp puis `rename` sur la cible. Un lecteur ne voit // jamais de fichier à moitié écrit (le rename est atomique sur le FS). - let tmp = self.providers_tmp_path(conversation); + let tmp = self.providers_tmp_path(conversation)?; tokio::fs::write(&tmp, &body) .await .map_err(|e| StoreError::Io(e.to_string()))?; - tokio::fs::rename(&tmp, self.providers_path(conversation)) + tokio::fs::rename(&tmp, self.providers_path(conversation)?) .await .map_err(|e| StoreError::Io(e.to_string()))?; Ok(()) diff --git a/crates/infrastructure/src/session/factory.rs b/crates/infrastructure/src/session/factory.rs index 76bdc3c..2252afe 100644 --- a/crates/infrastructure/src/session/factory.rs +++ b/crates/infrastructure/src/session/factory.rs @@ -207,6 +207,7 @@ impl AgentSessionFactory for StructuredSessionFactory { profile.command.clone(), profile.args.clone(), cwd, + seed, env.to_vec(), plan, enforcer, diff --git a/crates/infrastructure/src/session/opencode.rs b/crates/infrastructure/src/session/opencode.rs index 452cc5d..1e6419b 100644 --- a/crates/infrastructure/src/session/opencode.rs +++ b/crates/infrastructure/src/session/opencode.rs @@ -7,6 +7,7 @@ use std::process::Stdio; use std::sync::Arc; +use std::sync::Mutex; use async_trait::async_trait; use serde_json::Value; @@ -32,6 +33,24 @@ pub enum ParsedEvent { Ignored, } +/// Identifiant natif de session OpenCode exposé dans les événements JSONL. +pub fn extract_session_id(line: &str) -> Result, AgentSessionError> { + let trimmed = line.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let value: Value = serde_json::from_str(trimmed) + .map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?; + Ok(value + .get("sessionID") + .or_else(|| value.get("session_id")) + .or_else(|| value.get("sessionId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_owned)) +} + /// Message d'erreur par défaut quand `error` n'est ni une chaîne ni un objet exploitable. const OPENCODE_ERROR_FALLBACK: &str = "OpenCode a signalé une erreur sans détail exploitable"; @@ -190,6 +209,7 @@ pub struct OpenCodeSession { prefix_args: Vec, cwd: String, env: Vec<(String, String)>, + engine_session_id: Mutex>, sandbox: Option, sandbox_enforcer: Option>, } @@ -202,6 +222,7 @@ impl OpenCodeSession { command_prefix: impl Into, profile_args: Vec, cwd: impl Into, + seed: Option, env: Vec<(String, String)>, sandbox: Option, sandbox_enforcer: Option>, @@ -215,6 +236,7 @@ impl OpenCodeSession { prefix_args: prefix, cwd: cwd.into(), env, + engine_session_id: Mutex::new(seed), sandbox, sandbox_enforcer, }) @@ -222,14 +244,20 @@ impl OpenCodeSession { fn build_args(&self, prompt: &str) -> Vec { let mut args = self.prefix_args.clone(); - args.extend([ - "run".to_owned(), - "--format".to_owned(), - "json".to_owned(), - prompt.to_owned(), - ]); + args.extend(["run".to_owned(), "--format".to_owned(), "json".to_owned()]); + if let Some(engine_id) = self.conversation_id() { + args.extend(["--session".to_owned(), engine_id]); + } + args.push(prompt.to_owned()); args } + + fn capture_session_id(&self, engine_id: String) { + *self + .engine_session_id + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(engine_id); + } } #[async_trait] @@ -239,7 +267,10 @@ impl AgentSession for OpenCodeSession { } fn conversation_id(&self) -> Option { - None + self.engine_session_id + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() } async fn send(&self, prompt: &str) -> Result { @@ -277,6 +308,9 @@ impl AgentSession for OpenCodeSession { .await .map_err(|e| AgentSessionError::Io(e.to_string()))? { + if let Some(engine_id) = extract_session_id(&line)? { + self.capture_session_id(engine_id); + } collected.push(line); } @@ -512,6 +546,7 @@ exit 1 script.to_string_lossy(), Vec::new(), tmp.path().to_string_lossy(), + None, Vec::new(), None, None, @@ -534,4 +569,109 @@ exit 1 ] ); } + + #[test] + fn extract_session_id_accepts_opencode_jsonl_session_id_variants() { + assert_eq!( + extract_session_id(r#"{"type":"step_start","sessionID":"s1"}"#).unwrap(), + Some("s1".to_owned()) + ); + assert_eq!( + extract_session_id(r#"{"type":"step_start","session_id":"s2"}"#).unwrap(), + Some("s2".to_owned()) + ); + assert_eq!( + extract_session_id(r#"{"type":"step_start","sessionId":"s3"}"#).unwrap(), + Some("s3".to_owned()) + ); + assert_eq!( + extract_session_id(r#"{"type":"step_start","sessionID":" "}"#).unwrap(), + None + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn send_captures_engine_session_id_from_jsonl() { + let tmp = TempDir::new("capture-session"); + let script = tmp.path().join("opencode-fixture.sh"); + fs::write( + &script, + r#"#!/bin/sh +printf '%s\n' '{"type":"step_start","sessionID":"opencode-engine-1"}' +printf '%s\n' '{"type":"text","sessionID":"opencode-engine-1","part":{"text":"ok"}}' +printf '%s\n' '{"type":"step_finish","sessionID":"opencode-engine-1"}' +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(&script).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&script, permissions).unwrap(); + let session = OpenCodeSession::new( + SessionId::new_random(), + script.to_string_lossy(), + Vec::new(), + tmp.path().to_string_lossy(), + None, + Vec::new(), + None, + None, + ) + .unwrap(); + + assert_eq!(session.conversation_id(), None); + let events = session.send("prompt").await.unwrap().collect::>(); + + assert!(matches!(events.last(), Some(ReplyEvent::Final { .. }))); + assert_eq!( + session.conversation_id().as_deref(), + Some("opencode-engine-1") + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn seeded_or_captured_session_id_is_passed_as_opencode_session_arg() { + let tmp = TempDir::new("resume-arg"); + let script = tmp.path().join("opencode-fixture.sh"); + let args_file = tmp.path().join("args.txt"); + fs::write( + &script, + format!( + r#"#!/bin/sh +printf '%s\n' "$@" > '{}' +printf '%s\n' '{{"type":"step_start","sessionID":"seeded-engine"}}' +printf '%s\n' '{{"type":"text","sessionID":"seeded-engine","part":{{"text":"ok"}}}}' +printf '%s\n' '{{"type":"step_finish","sessionID":"seeded-engine"}}' +"#, + args_file.display() + ), + ) + .unwrap(); + let mut permissions = fs::metadata(&script).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&script, permissions).unwrap(); + let session = OpenCodeSession::new( + SessionId::new_random(), + script.to_string_lossy(), + Vec::new(), + tmp.path().to_string_lossy(), + Some("seeded-engine".to_owned()), + Vec::new(), + None, + None, + ) + .unwrap(); + + session.send("prompt").await.unwrap().for_each(drop); + + let args = fs::read_to_string(args_file).unwrap(); + let argv: Vec<&str> = args.lines().collect(); + assert!( + argv.windows(2) + .any(|pair| pair == ["--session", "seeded-engine"]), + "OpenCode resume must use `--session `, got: {argv:?}" + ); + assert_eq!(argv.last(), Some(&"prompt")); + } } diff --git a/crates/infrastructure/tests/conversation_log.rs b/crates/infrastructure/tests/conversation_log.rs index 11b76f7..7d20ece 100644 --- a/crates/infrastructure/tests/conversation_log.rs +++ b/crates/infrastructure/tests/conversation_log.rs @@ -1294,6 +1294,64 @@ async fn provider_is_isolated_per_conversation() { ); } +#[tokio::test] +async fn provider_is_isolated_per_project_root_with_same_conversation_and_provider() { + let project_a = TempDir::new(); + let project_b = TempDir::new(); + let store_a = FsProviderSessionStore::new(&project_a.project_path()); + let store_b = FsProviderSessionStore::new(&project_b.project_path()); + let c = conv_id(77); + + store_a.set(c, "opencode", "engine-a").await.unwrap(); + store_b.set(c, "opencode", "engine-b").await.unwrap(); + + assert_eq!( + store_a.get(c, "opencode").await.unwrap(), + Some("engine-a".to_owned()) + ); + assert_eq!( + store_b.get(c, "opencode").await.unwrap(), + Some("engine-b".to_owned()) + ); + assert!( + !std::fs::read_to_string(project_a.providers_path(c)) + .unwrap() + .contains("engine-b"), + "project A providers.json must not contain project B's engine id" + ); + assert!( + !std::fs::read_to_string(project_b.providers_path(c)) + .unwrap() + .contains("engine-a"), + "project B providers.json must not contain project A's engine id" + ); +} + +#[tokio::test] +async fn provider_unresolved_project_root_fails_closed() { + let root = ProjectPath::new(format!( + "{}/missing-{}", + std::env::temp_dir().to_string_lossy(), + Uuid::new_v4() + )) + .unwrap(); + let store = FsProviderSessionStore::new(&root); + let c = conv_id(78); + + assert_eq!( + store.get(c, "opencode").await.unwrap(), + None, + "unresolved root cannot produce a resumable id" + ); + assert!( + matches!( + store.set(c, "opencode", "engine").await, + Err(StoreError::Io(_)) + ), + "unresolved root must not write outside a canonical project root" + ); +} + // =========================================================================== // LS6 — rotation (ConversationArchive) + pagination humaine, I/O réelle. // Réutilise le scaffolding L2 (TempDir absolu, constructeurs déterministes). diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index f714198..8d55841 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -34,7 +34,7 @@ use tokio::task::JoinHandle; use uuid::Uuid; use application::{ - AssignIssueAgentInput, AssignSkillToAgentInput, AssignTicketToSprintInput, + AppError, AssignIssueAgentInput, AssignSkillToAgentInput, AssignTicketToSprintInput, AttachLiveAgentInput, AuthenticateSessionInput, ChangeAgentProfileInput, CloseTerminalInput, CreateAgentInput, CreateMemoryInput, CreateSkillInput, CreateSprintInput, DeleteAgentInput, DeleteEmbedderProfileInput, DeleteIssueInput, DeleteMemoryInput, DeleteSkillInput, @@ -2983,17 +2983,17 @@ async fn invoke_ticket_read(args: &Value, state: &BackendCore) -> Result Result Result { + match read_carnet + .execute(ReadIssueCarnetInput { + project: project.clone(), + issue_ref, + }) + .await + { + Ok(out) => Ok(out.carnet.carnet.as_str().to_owned()), + Err(AppError::NotFound(_)) => Ok(fallback_carnet), + Err(err) => Err(err), + } +} + async fn invoke_ticket_list(args: &Value, state: &BackendCore) -> Result { let request = required_request::("ticket_list", args)?; let project = resolve_project_readonly(&request.project_id, state).await?; @@ -7902,6 +7921,34 @@ mod tests { assert_eq!(read["ref"], ticket_ref); assert!(read["carnet"].is_string()); + let project = state + .app + .open_project + .execute(OpenProjectInput { + project_id: ProjectId::from_uuid(Uuid::parse_str(&project_id).unwrap()), + }) + .await + .expect("test project is readable") + .project; + std::fs::remove_file( + std::path::PathBuf::from(project.root.as_str()) + .join(".ideai") + .join("tickets") + .join(ticket_ref.trim_start_matches('#')) + .join("carnet.md"), + ) + .expect("test removes ticket carnet"); + let (status, read_without_carnet_file) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "ticket_read", + json!({ "request": { "projectId": project_id, "ref": ticket_ref, "includeCarnet": true } }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(read_without_carnet_file["ref"], ticket_ref); + assert_eq!(read_without_carnet_file["carnet"], ""); + let (status, list) = invoke_request_for_test( Arc::clone(&state), &cookie, diff --git a/frontend/src/features/agents/agents.test.tsx b/frontend/src/features/agents/agents.test.tsx index f1d4338..38b12a9 100644 --- a/frontend/src/features/agents/agents.test.tsx +++ b/frontend/src/features/agents/agents.test.tsx @@ -364,6 +364,21 @@ describe("AgentsPanel (with MockAgentGateway)", () => { const options = Array.from(select.options).map((o) => o.text); expect(options).toContain("Claude Code"); }); + + it("shows persisted OpenCode profiles in the agent profile dropdown", async () => { + const profile = new MockProfileGateway(); + const openCode = await profile.cloneOpenCodeProfileFromSeed({ + name: "OpenCode Local Durable", + }); + await profile.saveProfile(openCode); + + renderPanel(new MockAgentGateway(), profile); + await waitForIdle(); + + const select = screen.getByLabelText("agent profile") as HTMLSelectElement; + const options = Array.from(select.options).map((o) => o.text); + expect(options.some((text) => text.includes("OpenCode Local Durable"))).toBe(true); + }); }); // --------------------------------------------------------------------------- diff --git a/frontend/src/features/first-run/ProfilesSettings.test.tsx b/frontend/src/features/first-run/ProfilesSettings.test.tsx index c71eecd..de743bd 100644 --- a/frontend/src/features/first-run/ProfilesSettings.test.tsx +++ b/frontend/src/features/first-run/ProfilesSettings.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { DIProvider } from "@/app/di"; @@ -221,4 +221,70 @@ describe("ProfilesSettings", () => { ), ).toBeTruthy(); }); + + it("creates OpenCode profiles through the OpenCode clone flow and persists them for agent pickers", async () => { + const profile = new MockProfileGateway(); + const cloneOpenCodeSpy = vi.spyOn(profile, "cloneOpenCodeProfileFromSeed"); + const cloneGenericSpy = vi.spyOn(profile, "cloneProfileFromSeed"); + renderSettings(profile); + await waitReady(); + + fireEvent.click(screen.getByRole("tab", { name: "OpenCode-local" })); + await waitReady(); + + fireEvent.click(screen.getByRole("button", { name: "Creer un profil" })); + + const name = await screen.findByDisplayValue("OpenCode + llama.cpp copy"); + expect(name).toBeTruthy(); + await waitFor(async () => { + const saved = await profile.listProfiles(); + expect(saved.map((p) => p.name)).toContain("OpenCode + llama.cpp copy"); + expect(saved.some((p) => p.structuredAdapter === "openCode")).toBe(true); + }); + expect(cloneOpenCodeSpy).toHaveBeenCalledWith({ + name: "OpenCode + llama.cpp copy", + opencode: expect.objectContaining({ + baseURL: "http://localhost:8080/v1", + model: "qwen3-coder-30b", + }), + }); + expect(cloneGenericSpy).not.toHaveBeenCalled(); + }); + + it("duplicates OpenCode profiles through the OpenCode clone flow and preserves their local config", async () => { + const profile = new MockProfileGateway(); + renderSettings(profile); + await waitReady(); + + fireEvent.click(screen.getByRole("tab", { name: "OpenCode-local" })); + await waitReady(); + await createProfile(); + + const row = screen.getAllByRole("listitem")[0]; + fireEvent.change(within(row).getByLabelText("OpenCode + llama.cpp copy base url"), { + target: { value: "http://localhost:9090/v1" }, + }); + fireEvent.change(within(row).getByLabelText("OpenCode + llama.cpp copy model"), { + target: { value: "local-special" }, + }); + fireEvent.click(within(row).getByRole("button", { name: "Enregistrer" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + expect(saved[0]?.opencode?.baseURL).toBe("http://localhost:9090/v1"); + }); + + fireEvent.click(within(screen.getAllByRole("listitem")[0]).getByRole("button", { + name: "Dupliquer", + })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + const copies = saved.filter((p) => p.structuredAdapter === "openCode"); + expect(copies).toHaveLength(2); + expect(copies[1]?.name).toBe("OpenCode + llama.cpp copy copy"); + expect(copies[1]?.opencode?.baseURL).toBe("http://localhost:9090/v1"); + expect(copies[1]?.opencode?.model).toBe("local-special"); + }); + }); }); diff --git a/frontend/src/features/first-run/ProfilesSettings.tsx b/frontend/src/features/first-run/ProfilesSettings.tsx index 508ada7..a0e68f3 100644 --- a/frontend/src/features/first-run/ProfilesSettings.tsx +++ b/frontend/src/features/first-run/ProfilesSettings.tsx @@ -56,7 +56,10 @@ function describe(e: unknown): string { function tabFor(profile: AgentProfile): ProfileTab | null { if (profile.structuredAdapter === "codex") return "codex"; if (profile.structuredAdapter === "claude") return "claude"; - if (profile.structuredAdapter === "openCode" && profile.opencode) { + if ( + profile.structuredAdapter === "openCode" && + (profile.opencode || profile.opencodeProvider) + ) { return "openCode"; } return null; @@ -352,6 +355,16 @@ export function ProfilesSettings() { setBusy(true); setError(null); try { + if (activeTab === "openCode") { + const created = await profile.cloneOpenCodeProfileFromSeed({ + name: `${seed.name} copy`, + opencode: seed.opencode, + }); + await profile.saveProfile(created); + await refresh(); + return; + } + const models = activeTab === "codex" || activeTab === "claude" ? catalogue[activeTab].models @@ -404,6 +417,16 @@ export function ProfilesSettings() { setBusy(true); setError(null); try { + if (source.structuredAdapter === "openCode") { + const created = await profile.cloneOpenCodeProfileFromSeed({ + name: `${source.name} copy`, + opencode: source.opencode, + }); + await profile.saveProfile(created); + await refresh(); + return; + } + await profile.cloneProfileFromSeed({ seedProfileId: source.id, name: `${source.name} copy`,