From aab4bcafb6038fd6e5eee88143f9f95f45d50573 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 7 Jul 2026 22:09:16 +0200 Subject: [PATCH 1/2] feat(session): adapter HTTP OpenAI-compatible pour profils locaux/LAN (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute un adapter de session HTTP OpenAI-compatible, purement additif, permettant d'intégrer des modèles locaux/LAN comme profils IdeA canoniques avec parité tool-calling/MCP. - domain: extension du profil et des ports pour l'adapter OpenAI-compatible - infrastructure: adapter openai_compat + routage factory - app-tauri: mapping des outils OpenAI (openai_tools) + wiring state/lib - application: catalogue d'agents + tests de use-cases profils Validé QA (backend GO): round-trip byte-identique Claude/Codex, mapping erreurs, dégradation tools, conversation_id None, conformance un seul Final, routage factory. Suites vertes domain 467 / application 530 / infrastructure 523 / app-tauri 247, 0 échec. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 1 + Cargo.toml | 1 + crates/app-tauri/src/lib.rs | 1 + crates/app-tauri/src/openai_tools.rs | 149 +++ crates/app-tauri/src/state.rs | 12 +- crates/application/src/agent/catalogue.rs | 45 +- crates/application/tests/profile_usecases.rs | 54 +- crates/domain/Cargo.toml | 2 +- crates/domain/src/ports.rs | 43 + crates/domain/src/profile.rs | 171 +++ crates/infrastructure/Cargo.toml | 5 +- crates/infrastructure/src/session/factory.rs | 79 ++ crates/infrastructure/src/session/mod.rs | 112 +- .../src/session/openai_compat.rs | 1144 +++++++++++++++++ 14 files changed, 1794 insertions(+), 25 deletions(-) create mode 100644 crates/app-tauri/src/openai_tools.rs create mode 100644 crates/infrastructure/src/session/openai_compat.rs diff --git a/Cargo.lock b/Cargo.lock index a82eef4..0b5e50a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1942,6 +1942,7 @@ dependencies = [ "async-trait", "domain", "fastembed", + "futures-util", "git2", "landlock", "notify", diff --git a/Cargo.toml b/Cargo.toml index ad9005c..5af4c30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" async-trait = "0.1" +futures-util = "0.3" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] } # Local git via libgit2. Network features (https/ssh → openssl) are off for L8: # only local operations (status/commit/branch/checkout/log) are in scope; remote diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 8b79a5e..a578e40 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -19,6 +19,7 @@ pub mod dto; pub mod events; pub mod mcp_bridge; pub mod mcp_endpoint; +pub mod openai_tools; pub mod pty; pub mod state; pub mod tickets; diff --git a/crates/app-tauri/src/openai_tools.rs b/crates/app-tauri/src/openai_tools.rs new file mode 100644 index 0000000..fc1eceb --- /dev/null +++ b/crates/app-tauri/src/openai_tools.rs @@ -0,0 +1,149 @@ +//! ToolInvoker app-tauri pour l'adapter OpenAI-compatible. +//! +//! C'est la porte locale qui donne aux modèles HTTP la même surface `idea_*` que +//! le serveur MCP : même catalogue, même mapping en `OrchestratorCommand`, même +//! `OrchestratorService::dispatch`. + +use std::sync::{Arc, Mutex}; + +use application::OrchestratorService; +use async_trait::async_trait; +use domain::ports::{ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec}; +use serde_json::Value; + +const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot"; +const REQUESTER_ARG: &str = "__ideaRequester"; + +/// Invoker d'outils OpenAI-compatible branché sur l'orchestrateur applicatif. +pub struct AppOpenAiToolInvoker { + orchestrator: Arc, + projects: Arc, +} + +/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la +/// composition root. Il casse uniquement le cycle de wiring, pas le contrat runtime. +#[derive(Default)] +pub struct LateBoundOpenAiToolInvoker { + inner: Mutex>>, +} + +impl LateBoundOpenAiToolInvoker { + /// Construit un proxy vide. + #[must_use] + pub fn new() -> Self { + Self { + inner: Mutex::new(None), + } + } + + /// Lie l'implémentation réelle. Appelé une fois par la composition root. + pub fn bind(&self, inner: Arc) { + *self.inner.lock().expect("mutex sain") = Some(inner); + } +} + +#[async_trait] +impl ToolInvoker for LateBoundOpenAiToolInvoker { + fn tools(&self) -> Vec { + self.inner + .lock() + .expect("mutex sain") + .as_ref() + .map_or_else(Vec::new, |inner| inner.tools()) + } + + async fn call(&self, name: &str, args_json: &str) -> Result { + let inner = self + .inner + .lock() + .expect("mutex sain") + .clone() + .ok_or_else(|| { + ToolInvocationError::Execution("ToolInvoker OpenAI non initialisé".to_owned()) + })?; + inner.call(name, args_json).await + } +} + +impl AppOpenAiToolInvoker { + /// Construit l'invoker depuis le service orchestrateur et le store projet. + #[must_use] + pub fn new(orchestrator: Arc, projects: Arc) -> Self { + Self { + orchestrator, + projects, + } + } +} + +#[async_trait] +impl ToolInvoker for AppOpenAiToolInvoker { + fn tools(&self) -> Vec { + infrastructure::orchestrator::mcp::catalogue() + .into_iter() + .map(|tool| ToolSpec { + name: tool.name.to_owned(), + description: tool.description.to_owned(), + input_schema: tool.input_schema, + }) + .collect() + } + + async fn call(&self, name: &str, args_json: &str) -> Result { + let value: Value = serde_json::from_str(args_json) + .map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?; + let args = value.as_object().ok_or_else(|| { + ToolInvocationError::InvalidArguments( + "les arguments d'outil doivent être un objet JSON".to_owned(), + ) + })?; + let project_root = args + .get(PROJECT_ROOT_ARG) + .and_then(Value::as_str) + .ok_or_else(|| { + ToolInvocationError::InvalidArguments( + "contexte projet interne absent pour l'outil".to_owned(), + ) + })?; + let requester = args + .get(REQUESTER_ARG) + .and_then(Value::as_str) + .ok_or_else(|| { + ToolInvocationError::InvalidArguments( + "identité requester interne absente pour l'outil".to_owned(), + ) + })?; + let project = self + .projects + .list_projects() + .await + .map_err(|e| ToolInvocationError::Execution(e.to_string()))? + .into_iter() + .find(|project| project.root.as_str() == project_root) + .ok_or_else(|| { + ToolInvocationError::Execution(format!( + "projet introuvable pour root `{project_root}`" + )) + })?; + let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, requester) + .map_err(|e| match e { + infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => { + ToolInvocationError::NotFound(tool) + } + infrastructure::orchestrator::mcp::ToolMapError::BadArguments(tool) => { + ToolInvocationError::InvalidArguments(format!( + "arguments invalides pour `{tool}`" + )) + } + infrastructure::orchestrator::mcp::ToolMapError::Invalid(err) => { + ToolInvocationError::InvalidArguments(err.to_string()) + } + })?; + let outcome = self + .orchestrator + .dispatch(&project, command) + .await + .map_err(|e| ToolInvocationError::Execution(e.to_string()))?; + Ok(outcome.reply.unwrap_or(outcome.detail)) + } +} diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 90314e7..6af05b3 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -49,7 +49,7 @@ use domain::ports::{ EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, TemplateStore, - WakeError, WakeReason, + ToolInvoker, WakeError, WakeReason, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -81,6 +81,7 @@ use infrastructure::{ use crate::chat::ChatBridge; use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint}; +use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker}; use crate::pty::PtyBridge; use crate::tickets::AppTicketToolProvider; @@ -1084,9 +1085,12 @@ impl AppState { // `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et // ChangeAgentProfile (shutdown polymorphe au hot-swap). let structured_sessions = Arc::new(StructuredSessions::new()); + let openai_tool_invoker = Arc::new(LateBoundOpenAiToolInvoker::new()); + let openai_tool_invoker_port = Arc::clone(&openai_tool_invoker) as Arc; let session_factory = Arc::new( StructuredSessionFactory::new() - .with_sandbox_enforcer(infrastructure::default_enforcer()), + .with_sandbox_enforcer(infrastructure::default_enforcer()) + .with_tool_invoker(openai_tool_invoker_port), ) as Arc; let open_terminal = Arc::new(OpenTerminal::new( @@ -2146,6 +2150,10 @@ impl AppState { // même use case que la commande Tauri, sans aller-retour frontend. .with_spawn_background_command(Arc::clone(&spawn_background_command)), ); + openai_tool_invoker.bind(Arc::new(AppOpenAiToolInvoker::new( + Arc::clone(&orchestrator_service), + Arc::clone(&store_port), + )) as Arc); let stop_live_agent = Arc::new( StopLiveAgent::new(Arc::clone(&live_sessions), Arc::clone(&close_terminal)) diff --git a/crates/application/src/agent/catalogue.rs b/crates/application/src/agent/catalogue.rs index 35d9022..7417f75 100644 --- a/crates/application/src/agent/catalogue.rs +++ b/crates/application/src/agent/catalogue.rs @@ -20,7 +20,7 @@ use domain::ids::ProfileId; use domain::permission::ProjectorKey; use domain::profile::{ - AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, + AgentProfile, ContextInjection, HttpChatConfig, McpCapability, McpConfigStrategy, McpTransport, StructuredAdapter, }; @@ -96,6 +96,30 @@ pub fn reference_profiles() -> Vec { .expect(".codex/config.toml + CODEX_HOME is a valid MCP config target"), McpTransport::Stdio, )), + AgentProfile::new( + reference_id("ollama-openai-compatible"), + "Ollama / OpenAI-compatible local model", + "openai-compatible", + Vec::new(), + ContextInjection::convention_file("AGENTS.md") + .expect("AGENTS.md is a valid convention target"), + None, + "{agentRunDir}", + None, + ) + .expect("OpenAI-compatible reference profile is valid") + .with_structured_adapter(StructuredAdapter::OpenAiCompatible) + .with_chat_http( + HttpChatConfig::new( + "http://localhost:11434/v1", + "qwen2.5-coder", + None, + Some(120_000), + Some(5_000), + Some(HttpChatConfig::DEFAULT_MAX_TOOL_ITERATIONS), + ) + .expect("OpenAI-compatible HTTP config is valid"), + ), AgentProfile::new( reference_id("gemini"), "Gemini CLI", @@ -129,8 +153,9 @@ pub fn reference_profiles() -> Vec { /// /// A profile is selectable iff it can be driven in **structured** mode /// ([`AgentProfile::is_selectable`] = it carries a `structured_adapter`). Today -/// that is Claude + Codex; Gemini/Aider stay in [`reference_profiles`] (the data -/// catalogue is untouched) but are **not** proposed for selection. There is no +/// that is Claude + Codex + OpenAI-compatible; Gemini/Aider stay in +/// [`reference_profiles`] (the data catalogue is untouched) but are **not** +/// proposed for selection. There is no /// custom-profile entry here either: the selection path offers only profiles we /// know how to pilot. /// @@ -201,6 +226,20 @@ mod mcp_tests { ); } + #[test] + fn openai_compatible_seed_is_selectable_http_profile_without_mcp() { + let profile = profile("ollama-openai-compatible"); + assert_eq!( + profile.structured_adapter, + Some(StructuredAdapter::OpenAiCompatible) + ); + let chat = profile.chat_http.as_ref().expect("chatHttp present"); + assert_eq!(chat.endpoint, "http://localhost:11434/v1"); + assert_eq!(chat.model, "qwen2.5-coder"); + assert!(profile.mcp.is_none()); + assert!(profile.is_selectable()); + } + #[test] fn gemini_and_aider_have_no_mcp_capability() { for slug in ["gemini", "aider"] { diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 1f31c49..7b8cebe 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -217,10 +217,11 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() { let out = uc.execute().await.unwrap(); assert!(out.is_first_run); // §17.3/D7: the wizard is seeded only with the *selectable* (structured- - // drivable) profiles — Claude + Codex — not the full 4-profile catalogue. + // drivable) profiles — Claude + Codex + OpenAI-compatible — not the full + // catalogue. assert_eq!( out.reference_profiles.len(), - 2, + 3, "selectable catalogue seeded" ); let commands: Vec<&str> = out @@ -230,8 +231,8 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() { .collect(); assert_eq!( commands, - vec!["claude", "codex"], - "only Claude/Codex offered" + vec!["claude", "codex", "openai-compatible"], + "only structured profiles offered" ); // Every seeded profile is selectable (the gate the menu relies on). assert!( @@ -299,32 +300,57 @@ async fn delete_unknown_is_not_found_error() { #[tokio::test] async fn reference_profiles_use_case_returns_only_selectable() { // §17.3/D7: the selection use case exposes only structured-drivable profiles - // (Claude + Codex). Gemini/Aider stay in the raw catalogue (see + // (Claude + Codex + OpenAI-compatible). Gemini/Aider stay in the raw catalogue (see // `catalogue_*` tests below) but are not offered to selection/creation. let out = ReferenceProfiles::new().execute().await.unwrap(); - assert_eq!(out.profiles.len(), 2); + assert_eq!(out.profiles.len(), 3); let commands: Vec<&str> = out.profiles.iter().map(|p| p.command.as_str()).collect(); - assert_eq!(commands, vec!["claude", "codex"]); + assert_eq!(commands, vec!["claude", "codex", "openai-compatible"]); assert!(out.profiles.iter().all(AgentProfile::is_selectable)); } -/// §17.3/D7 — non-regression: the *raw* catalogue still carries the four profiles +/// §17.3/D7 — non-regression: the *raw* catalogue still carries all reference profiles /// (data intact). Only the selection-facing use case is filtered. #[test] -fn raw_catalogue_still_has_all_four_profiles() { +fn raw_catalogue_still_has_all_profiles() { let commands: Vec = reference_profiles() .iter() .map(|p| p.command.clone()) .collect(); - assert_eq!(commands, vec!["claude", "codex", "gemini", "aider"]); + assert_eq!( + commands, + vec!["claude", "codex", "openai-compatible", "gemini", "aider"] + ); } -/// §17.3/D7 — `is_selectable` is the single selection predicate: true for the two +#[test] +fn claude_and_codex_reference_profiles_roundtrip_with_byte_identity() { + let profiles = reference_profiles(); + let by_command: HashMap<&str, &AgentProfile> = + profiles.iter().map(|p| (p.command.as_str(), p)).collect(); + + for command in ["claude", "codex"] { + let before = serde_json::to_vec(by_command[command]).expect("serialize reference profile"); + let back: AgentProfile = + serde_json::from_slice(&before).expect("deserialize reference profile"); + let after = serde_json::to_vec(&back).expect("serialize round-tripped profile"); + assert_eq!( + after, before, + "{command} profile JSON bytes must be strictly stable across a serde round-trip" + ); + assert!( + !String::from_utf8_lossy(&after).contains("\"chatHttp\""), + "{command} is a historical non-HTTP profile and must not grow chatHttp" + ); + } +} + +/// §17.3/D7 — `is_selectable` is the single selection predicate: true for the /// structured-drivable profiles, false for the two PTY-only ones. This assertion /// would flip (and fail) the moment Gemini/Aider gained an adapter or Claude/Codex /// lost theirs — i.e. it actually constrains behaviour. #[test] -fn is_selectable_is_true_only_for_claude_and_codex() { +fn is_selectable_is_true_only_for_structured_profiles() { let profiles = reference_profiles(); let by_command: HashMap<&str, &AgentProfile> = profiles.iter().map(|p| (p.command.as_str(), p)).collect(); @@ -336,6 +362,10 @@ fn is_selectable_is_true_only_for_claude_and_codex() { by_command["codex"].is_selectable(), "Codex carries a structured adapter ⇒ selectable" ); + assert!( + by_command["openai-compatible"].is_selectable(), + "OpenAI-compatible carries a structured adapter ⇒ selectable" + ); assert!( !by_command["gemini"].is_selectable(), "Gemini has no adapter ⇒ not selectable" diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index 8aa9c46..75ad042 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -9,9 +9,9 @@ description = "IdeA — pure domain layer: entities, value objects, ports (trait [dependencies] uuid = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } [dev-dependencies] -serde_json = { workspace = true } tokio = { workspace = true } diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 8b988ec..124350f 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -25,6 +25,7 @@ use std::sync::Arc; use async_trait::async_trait; +use serde_json::Value; use thiserror::Error; use crate::agent::AgentManifest; @@ -368,6 +369,48 @@ pub enum ReplyEvent { /// clôt le flux ; deltas, activités et heartbeats sont tous non terminaux. pub type ReplyStream = Box + Send>; +/// Description pure d'un outil exposé à un modèle OpenAI-compatible. +#[derive(Debug, Clone, PartialEq)] +pub struct ToolSpec { + /// Nom stable de l'outil (ex. `idea_ask_agent`). + pub name: String, + /// Description destinée au modèle. + pub description: String, + /// Schéma JSON d'entrée de l'outil. + pub input_schema: Value, +} + +/// Erreurs du port d'invocation d'outils agentiques. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ToolInvocationError { + /// Outil inconnu ou non exposé par l'orchestrateur. + #[error("tool not found: {0}")] + NotFound(String), + /// Arguments invalides ou non conformes au schéma attendu. + #[error("tool invalid arguments: {0}")] + InvalidArguments(String), + /// Refus par gating produit/permissions. + #[error("tool rejected: {0}")] + Rejected(String), + /// Échec d'exécution de l'outil. + #[error("tool execution failed: {0}")] + Execution(String), +} + +/// Port pur d'invocation des outils `idea_*` pour les modèles qui n'ont pas de +/// client MCP natif. L'impl concrète vit hors domaine. +#[async_trait] +pub trait ToolInvoker: Send + Sync { + /// Liste des outils exposés au modèle. + fn tools(&self) -> Vec; + + /// Appelle un outil avec ses arguments JSON bruts. + /// + /// # Errors + /// [`ToolInvocationError`] si l'outil est refusé, introuvable ou échoue. + async fn call(&self, name: &str, args_json: &str) -> Result; +} + /// Intention **model-agnostique** exécutée à l'échéance d'un réveil de /// [`Scheduler`] (ARCHITECTURE §21.4). /// diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index c36df0d..784b389 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -246,6 +246,8 @@ pub enum StructuredAdapter { Claude, /// Piloté par `CodexExecSession` (`codex exec` structuré). Codex, + /// Piloté par l'adapter HTTP OpenAI-compatible (Ollama, llama.cpp, runtime LAN). + OpenAiCompatible, } impl StructuredAdapter { @@ -262,6 +264,98 @@ impl StructuredAdapter { match self { Self::Claude => "claude", Self::Codex => "codex", + Self::OpenAiCompatible => "openai-compatible", + } + } +} + +/// Configuration HTTP d'un serveur de chat OpenAI-compatible. +/// +/// Pure donnée domaine : l'endpoint est validé syntaxiquement mais jamais contacté, +/// et `api_key_env` porte uniquement le NOM de variable d'environnement à résoudre +/// côté infrastructure. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HttpChatConfig { + /// Endpoint racine ou endpoint `/chat/completions` du serveur HTTP. + pub endpoint: String, + /// Nom du modèle envoyé dans le payload `/chat/completions`. + pub model: String, + /// Nom optionnel de la variable d'environnement contenant la clé API. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key_env: Option, + /// Timeout de requête par tour, en millisecondes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_timeout_ms: Option, + /// Timeout de connexion, en millisecondes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connect_timeout_ms: Option, + /// Plafond de rebouclage tool-calling pour un tour. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tool_iterations: Option, +} + +impl HttpChatConfig { + /// Défaut produit du garde-fou tool-calling local/LAN. + pub const DEFAULT_MAX_TOOL_ITERATIONS: u16 = 16; + + /// Construit une configuration validée (parse-don't-validate, sans I/O). + /// + /// # Errors + /// Renvoie une erreur domaine si l'endpoint n'est pas HTTP(S), si le modèle est + /// vide, si `api_key_env` n'est pas un nom d'env valide, ou si un timeout/plafond + /// vaut zéro. + pub fn new( + endpoint: impl Into, + model: impl Into, + api_key_env: Option, + request_timeout_ms: Option, + connect_timeout_ms: Option, + max_tool_iterations: Option, + ) -> Result { + let endpoint = endpoint.into(); + let model = model.into(); + crate::validation::non_empty(&endpoint, "chatHttp.endpoint")?; + if !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) { + return Err(DomainError::Invariant( + "chatHttp.endpoint must start with http:// or https://".to_owned(), + )); + } + crate::validation::non_empty(&model, "chatHttp.model")?; + if let Some(var) = &api_key_env { + crate::validation::valid_env_var(var)?; + } + if let Some(0) = request_timeout_ms { + return Err(DomainError::EmptyField { + field: "chatHttp.requestTimeoutMs", + }); + } + if let Some(0) = connect_timeout_ms { + return Err(DomainError::EmptyField { + field: "chatHttp.connectTimeoutMs", + }); + } + if let Some(0) = max_tool_iterations { + return Err(DomainError::EmptyField { + field: "chatHttp.maxToolIterations", + }); + } + Ok(Self { + endpoint, + model, + api_key_env, + request_timeout_ms, + connect_timeout_ms, + max_tool_iterations, + }) + } + + /// Plafond effectif de rebouclage tool-calling. + #[must_use] + pub const fn effective_max_tool_iterations(&self) -> u16 { + match self.max_tool_iterations { + Some(value) => value, + None => Self::DEFAULT_MAX_TOOL_ITERATIONS, } } } @@ -569,6 +663,10 @@ pub struct AgentProfile { /// sérialisation : un profil sans adapter sérialise exactement comme avant. #[serde(default, skip_serializing_if = "Option::is_none")] pub structured_adapter: Option, + /// Configuration HTTP pour [`StructuredAdapter::OpenAiCompatible`]. `None` pour + /// tous les profils historiques et tous les adapters non HTTP. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chat_http: Option, /// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1). /// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel). /// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et @@ -772,6 +870,7 @@ impl AgentProfile { cwd_template, session, structured_adapter: None, + chat_http: None, mcp: None, liveness: None, rate_limit_pattern: None, @@ -790,6 +889,13 @@ impl AgentProfile { self } + /// Builder : fixe la configuration HTTP chat OpenAI-compatible. + #[must_use] + pub fn with_chat_http(mut self, config: HttpChatConfig) -> Self { + self.chat_http = Some(config); + self + } + /// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les /// profils sans MCP ne l'appellent simplement pas. @@ -886,6 +992,7 @@ impl AgentProfile { (Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => { true } + (Some(StructuredAdapter::OpenAiCompatible), _) => false, _ => false, } } @@ -934,6 +1041,70 @@ mod mcp_tests { assert!(back.mcp.is_none()); } + #[test] + fn profile_without_chat_http_round_trips_identically() { + let profile = profile_without_mcp() + .with_structured_adapter(StructuredAdapter::Claude) + .with_mcp(McpCapability::new( + McpConfigStrategy::config_file(".mcp.json").expect("valid target"), + McpTransport::Stdio, + )); + let json = serde_json::to_string(&profile).expect("serialise"); + assert!( + !json.contains("\"chatHttp\""), + "a non-HTTP profile must NOT serialise `chatHttp`: {json}" + ); + let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(profile, back); + assert!(back.chat_http.is_none()); + } + + #[test] + fn openai_compatible_adapter_serialises_camel_case_and_provider_key_is_stable() { + let adapter = StructuredAdapter::OpenAiCompatible; + assert_eq!(adapter.provider_key(), "openai-compatible"); + let json = serde_json::to_string(&adapter).expect("serialise"); + assert_eq!(json, "\"openAiCompatible\""); + let back: StructuredAdapter = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(back, adapter); + } + + #[test] + fn http_chat_config_validates_without_io_and_round_trips() { + let config = HttpChatConfig::new( + "http://127.0.0.1:11434/v1", + "qwen2.5-coder", + Some("OLLAMA_API_KEY".to_owned()), + Some(30_000), + Some(3_000), + Some(8), + ) + .expect("valid"); + assert_eq!(config.effective_max_tool_iterations(), 8); + let json = serde_json::to_string(&config).expect("serialise"); + assert!(json.contains("\"apiKeyEnv\":\"OLLAMA_API_KEY\"")); + let back: HttpChatConfig = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(back, config); + } + + #[test] + fn http_chat_config_rejects_invalid_values() { + assert!(HttpChatConfig::new("ftp://host", "model", None, None, None, None).is_err()); + assert!(HttpChatConfig::new("http://host", " ", None, None, None, None).is_err()); + assert!(HttpChatConfig::new( + "http://host", + "model", + Some("not-valid".to_owned()), + None, + None, + None + ) + .is_err()); + assert!(HttpChatConfig::new("http://host", "model", None, Some(0), None, None).is_err()); + assert!(HttpChatConfig::new("http://host", "model", None, None, Some(0), None).is_err()); + assert!(HttpChatConfig::new("http://host", "model", None, None, None, Some(0)).is_err()); + } + #[test] fn legacy_json_without_mcp_field_deserialises_to_none() { // JSON produced before the `mcp` field existed: no `mcp` key at all. diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml index 3bcfd6e..057bafd 100644 --- a/crates/infrastructure/Cargo.toml +++ b/crates/infrastructure/Cargo.toml @@ -16,6 +16,7 @@ application = { workspace = true } tokio = { workspace = true, features = ["process", "time"] } uuid = { workspace = true } async-trait = { workspace = true } +futures-util = { workspace = true } # Ergonomic error enums for the MCP adapter (tool-mapping / transport errors). thiserror = { workspace = true } serde = { workspace = true } @@ -38,7 +39,7 @@ notify = "6" # §14.5.3). `default-features = false` + `rustls-tls` keeps it OpenSSL-free so the # AppImage stays portable; pulled in *only* under the `vector-http` feature so the # zero-dependency default (`none` strategy) compiles nothing extra. -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } # Optional in-process ONNX embedder (LOT C1b, §14.5.3). rustls all the way down # (HF model download + ort binaries fetched at build time, NOT load-dynamic) so the # AppImage stays self-contained and OpenSSL-free. Pulled in *only* under the @@ -51,6 +52,6 @@ landlock = "0.4.5" [features] # Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…). # OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency. -vector-http = ["dep:reqwest"] +vector-http = [] # Real in-process ONNX embedder (`localOnnx`). OFF by default, same posture. vector-onnx = ["dep:fastembed"] diff --git a/crates/infrastructure/src/session/factory.rs b/crates/infrastructure/src/session/factory.rs index 77e57fc..798bfe7 100644 --- a/crates/infrastructure/src/session/factory.rs +++ b/crates/infrastructure/src/session/factory.rs @@ -10,9 +10,11 @@ use std::sync::Arc; use async_trait::async_trait; +use serde_json::Value; use domain::ports::{ AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, SessionPlan, + ToolInvocationError, ToolInvoker, ToolSpec, }; use domain::profile::{AgentProfile, StructuredAdapter}; use domain::project::ProjectPath; @@ -21,6 +23,46 @@ use domain::SessionId; use super::claude::ClaudeSdkSession; use super::codex::CodexExecSession; +use super::openai_compat::OpenAiCompatibleSession; + +const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot"; +const REQUESTER_ARG: &str = "__ideaRequester"; + +struct ProjectScopedToolInvoker { + inner: Arc, + project_root: String, + requester: String, +} + +#[async_trait] +impl ToolInvoker for ProjectScopedToolInvoker { + fn tools(&self) -> Vec { + self.inner.tools() + } + + async fn call(&self, name: &str, args_json: &str) -> Result { + let mut value: Value = serde_json::from_str(args_json) + .map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?; + match &mut value { + Value::Object(map) => { + map.insert( + PROJECT_ROOT_ARG.to_owned(), + Value::String(self.project_root.clone()), + ); + map.insert( + REQUESTER_ARG.to_owned(), + Value::String(self.requester.clone()), + ); + } + _ => { + return Err(ToolInvocationError::InvalidArguments( + "les arguments d'outil doivent être un objet JSON".to_owned(), + )); + } + } + self.inner.call(name, &value.to_string()).await + } +} /// Fabrique infra des sessions structurées, sélectionnée par le profil. /// @@ -35,6 +77,8 @@ pub struct StructuredSessionFactory { /// Enforcer OS optionnel passé aux adapters structurés. `None` ⇒ aucun /// sandboxing (chemin natif inchangé, zéro régression). sandbox_enforcer: Option>, + /// Invoker outil optionnel pour les adapters sans client MCP natif. + tool_invoker: Option>, } impl StructuredSessionFactory { @@ -43,6 +87,7 @@ impl StructuredSessionFactory { pub fn new() -> Self { Self { sandbox_enforcer: None, + tool_invoker: None, } } @@ -55,6 +100,14 @@ impl StructuredSessionFactory { self.sandbox_enforcer = Some(enforcer); self } + + /// Builder additif : câble l'invocation d'outils `idea_*` pour les moteurs HTTP + /// sans client MCP natif. `None` (défaut) ⇒ chat nu. + #[must_use] + pub fn with_tool_invoker(mut self, invoker: Arc) -> Self { + self.tool_invoker = Some(invoker); + self + } } /// Dérive l'[`SessionPlan`] le `seed` de reprise : seul [`SessionPlan::Resume`] @@ -95,6 +148,11 @@ impl AgentSessionFactory for StructuredSessionFactory { let command = profile.command.clone(); let cwd = cwd.as_str().to_owned(); let seed = seed_conversation_id(session); + let requester = std::path::Path::new(&cwd) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_owned(); // Appariement (lot LP4-4) : plan **par lancement** (param) + enforcer **par // instance** (champ). Tous deux sont relayés à l'adapter, qui remplira @@ -121,6 +179,27 @@ impl AgentSessionFactory for StructuredSessionFactory { plan, enforcer, )), + StructuredAdapter::OpenAiCompatible => { + let config = profile.chat_http.clone().ok_or_else(|| { + AgentSessionError::Start(format!( + "le profil « {} » n'a pas de configuration chatHttp", + profile.name + )) + })?; + Arc::new(OpenAiCompatibleSession::new( + id, + config, + &cwd, + ctx.content.as_str(), + self.tool_invoker.clone().map(|inner| { + Arc::new(ProjectScopedToolInvoker { + inner, + project_root: ctx.project_root.clone(), + requester: requester.clone(), + }) as Arc + }), + )?) + } }; Ok(session) } diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index 391420a..cb24715 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -23,6 +23,7 @@ pub mod claude; pub mod codex; pub mod conformance; pub mod factory; +pub mod openai_compat; pub mod process; /// Tests bout-en-bout de l'enforcement Landlock sur le chemin structuré (lot LP4-4), @@ -34,18 +35,22 @@ pub use claude::ClaudeSdkSession; pub use codex::CodexExecSession; pub use conformance::FakeCli; pub use factory::StructuredSessionFactory; +pub use openai_compat::OpenAiCompatibleSession; #[cfg(test)] mod tests { use std::sync::Arc; use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + use domain::ids::ProfileId; use domain::ports::{ AgentSession, AgentSessionError, AgentSessionFactory, ContextInjectionPlan, PreparedContext, ReplyEvent, SessionPlan, }; - use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter}; + use domain::profile::{AgentProfile, ContextInjection, HttpChatConfig, StructuredAdapter}; use domain::project::ProjectPath; use domain::{MarkdownDoc, SessionId}; @@ -70,8 +75,17 @@ mod tests { ProjectPath::new("/").expect("cwd valide") } + fn temp_cwd(name: &str) -> ProjectPath { + let path = std::env::temp_dir().join(format!( + "idea-structured-session-{name}-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&path).expect("temp cwd"); + ProjectPath::new(path.to_string_lossy().into_owned()).expect("temp cwd path") + } + fn structured_profile(adapter: StructuredAdapter, command: &str) -> AgentProfile { - AgentProfile::new( + let profile = AgentProfile::new( ProfileId::new_random(), "Profil structuré", command, @@ -82,7 +96,57 @@ mod tests { None, ) .expect("profil valide") - .with_structured_adapter(adapter) + .with_structured_adapter(adapter); + if adapter == StructuredAdapter::OpenAiCompatible { + profile.with_chat_http( + HttpChatConfig::new( + "http://127.0.0.1:9/v1", + "local-model", + None, + Some(1_000), + Some(100), + Some(1), + ) + .expect("valid http config"), + ) + } else { + profile + } + } + + async fn one_shot_chat_server(content: &'static str) -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + let handle = tokio::spawn(async move { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 1024]; + loop { + let n = socket.read(&mut chunk).await.expect("read request"); + if n == 0 { + return; + } + buffer.extend_from_slice(&chunk[..n]); + if buffer.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let body = format!( + r#"{{"choices":[{{"message":{{"role":"assistant","content":"{content}"}}}}]}}"# + ); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + socket + .write_all(response.as_bytes()) + .await + .expect("write response"); + }); + (format!("http://{addr}/v1"), handle) } // -- Machinerie de process (paramétrable, fake CLI) ------------------- @@ -404,13 +468,14 @@ mod tests { let mut expected: HashMap<&str, bool> = HashMap::new(); expected.insert("claude", true); expected.insert("codex", true); + expected.insert("openai-compatible", true); expected.insert("gemini", false); expected.insert("aider", false); assert_eq!( profiles.len(), - 4, - "catalogue has the four reference profiles" + 5, + "catalogue has the five reference profiles" ); for profile in &profiles { let selectable = profile.is_selectable(); @@ -454,6 +519,43 @@ mod tests { assert_eq!(content_cx, "réponse Codex"); } + #[tokio::test] + async fn factory_routes_openai_compatible_to_http_session() { + let factory = StructuredSessionFactory::new(); + let (endpoint, handle) = one_shot_chat_server("réponse HTTP").await; + let openai = structured_profile(StructuredAdapter::OpenAiCompatible, "openai-compatible") + .with_chat_http( + HttpChatConfig::new( + endpoint, + "local-model", + None, + Some(10_000), + Some(1_000), + None, + ) + .expect("valid http config"), + ); + let session = factory + .start( + &openai, + &prepared_ctx(), + &temp_cwd("factory-openai"), + &SessionPlan::None, + None, + ) + .await + .expect("start OpenAI-compatible ok"); + + assert_eq!( + session.conversation_id(), + None, + "OpenAI-compatible route must not expose a provider conversation id" + ); + let content = drain_final(session.as_ref()).await; + assert_eq!(content, "réponse HTTP"); + handle.abort(); + } + #[tokio::test] async fn factory_passes_project_root_to_codex_add_dir() { let factory = StructuredSessionFactory::new(); diff --git a/crates/infrastructure/src/session/openai_compat.rs b/crates/infrastructure/src/session/openai_compat.rs new file mode 100644 index 0000000..326d222 --- /dev/null +++ b/crates/infrastructure/src/session/openai_compat.rs @@ -0,0 +1,1144 @@ +//! Adapter de session HTTP OpenAI-compatible (Ollama, llama.cpp, runtime LAN). +//! +//! Contrairement aux adapters CLI, la boucle agentique est conduite par IdeA : +//! transcript local, appels HTTP `/chat/completions`, invocation in-process des +//! outils, puis rebouclage borné. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use futures_util::StreamExt; +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use domain::ports::{ + AgentSession, AgentSessionError, ReplyEvent, ReplyStream, ToolInvoker, ToolSpec, +}; +use domain::profile::{HttpChatConfig, StructuredAdapter}; +use domain::SessionId; + +const TRANSCRIPT_FILE: &str = "chat-transcript.json"; + +/// Appel d'outil OpenAI-compatible normalisé pour la boucle agentique. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenAiToolCall { + /// Identifiant OpenAI-compatible de l'appel d'outil. + pub id: String, + /// Nom de la fonction appelée. + pub name: String, + /// Arguments JSON bruts fournis par le modèle. + pub arguments: String, +} + +/// Résultat d'un chunk streaming OpenAI-compatible. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ParsedChatDelta { + /// Fragment de texte assistant. + pub text: String, + /// Appels d'outils annoncés par ce chunk. + pub tool_calls: Vec, +} + +/// Résultat d'une completion non-streaming OpenAI-compatible. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ParsedCompletion { + /// Contenu assistant complet. + pub content: String, + /// Appels d'outils demandés par le modèle. + pub tool_calls: Vec, +} + +#[derive(Debug, Default)] +struct StreamState { + content: String, + tool_calls: Vec, +} + +#[derive(Debug, Clone, Default)] +struct ToolCallBuilder { + id: String, + name: String, + arguments: String, +} + +impl ToolCallBuilder { + fn finish(self) -> Option { + if self.name.is_empty() { + None + } else { + Some(OpenAiToolCall { + id: if self.id.is_empty() { + format!("call_{}", self.name) + } else { + self.id + }, + name: self.name, + arguments: self.arguments, + }) + } + } +} + +/// Parse un chunk SSE `chat.completion.chunk` OpenAI-compatible. +/// +/// # Errors +/// [`AgentSessionError::Decode`] si le JSON est illisible. Le payload brut ne fuit +/// jamais dans le message. +pub fn parse_chat_delta(line: &str) -> Result { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed == "[DONE]" { + return Ok(ParsedChatDelta::default()); + } + let value: Value = serde_json::from_str(trimmed) + .map_err(|e| AgentSessionError::Decode(format!("chat delta JSON illisible: {e}")))?; + let Some(delta) = value + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("delta")) + else { + return Ok(ParsedChatDelta::default()); + }; + + let text = delta + .get("content") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let tool_calls = parse_tool_calls_array(delta.get("tool_calls")); + Ok(ParsedChatDelta { text, tool_calls }) +} + +/// Parse une réponse non-streaming `/chat/completions`. +/// +/// # Errors +/// [`AgentSessionError::Decode`] si le JSON est illisible. Le payload brut ne fuit +/// jamais dans le message. +pub fn parse_completion(body: &str) -> Result { + let value: Value = serde_json::from_str(body) + .map_err(|e| AgentSessionError::Decode(format!("chat completion JSON illisible: {e}")))?; + let Some(message) = value + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("message")) + else { + return Err(AgentSessionError::Decode( + "chat completion schema missing choices[0].message".to_owned(), + )); + }; + Ok(ParsedCompletion { + content: message + .get("content") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + tool_calls: parse_tool_calls_array(message.get("tool_calls")), + }) +} + +fn parse_tool_calls_array(value: Option<&Value>) -> Vec { + value + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| { + let function = item.get("function")?; + let name = function + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(); + let arguments = function + .get("arguments") + .and_then(Value::as_str) + .unwrap_or_default(); + if name.is_empty() && arguments.is_empty() { + return None; + } + Some(OpenAiToolCall { + id: item + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + name: name.to_owned(), + arguments: arguments.to_owned(), + }) + }) + .collect() +} + +/// Session structurée OpenAI-compatible, stateless côté provider et persistée côté IdeA. +pub struct OpenAiCompatibleSession { + id: SessionId, + client: Client, + config: HttpChatConfig, + endpoint: String, + api_key: Option, + transcript_path: PathBuf, + transcript: Mutex>, + tool_invoker: Option>, + tools_disabled: Mutex, + tools_rejection_reported: Mutex, + first_contact: Mutex, +} + +impl OpenAiCompatibleSession { + /// Construit l'adapter depuis une config domaine validée. + /// + /// # Errors + /// [`AgentSessionError::Start`] si la clé d'environnement déclarée est absente + /// ou si le client HTTP ne peut pas être construit. + pub fn new( + id: SessionId, + config: HttpChatConfig, + run_dir: impl AsRef, + system_context: &str, + tool_invoker: Option>, + ) -> Result { + let mut builder = Client::builder(); + if let Some(ms) = config.connect_timeout_ms { + builder = builder.connect_timeout(Duration::from_millis(u64::from(ms))); + } + if let Some(ms) = config.request_timeout_ms { + builder = builder.timeout(Duration::from_millis(u64::from(ms))); + } + let client = builder + .build() + .map_err(|e| AgentSessionError::Start(format!("client HTTP invalide: {e}")))?; + let api_key = match &config.api_key_env { + Some(var) => Some(std::env::var(var).map_err(|_| { + AgentSessionError::Start(format!( + "variable d'environnement `{var}` introuvable pour la clé API" + )) + })?), + None => None, + }; + let transcript_path = run_dir.as_ref().join(TRANSCRIPT_FILE); + let transcript = load_or_seed_transcript(&transcript_path, system_context)?; + Ok(Self { + id, + endpoint: chat_completions_endpoint(&config.endpoint), + client, + config, + api_key, + transcript_path, + transcript: Mutex::new(transcript), + tool_invoker, + tools_disabled: Mutex::new(false), + tools_rejection_reported: Mutex::new(false), + first_contact: Mutex::new(true), + }) + } + + async fn send_inner( + &self, + prompt: &str, + tap: Option>, + ) -> Result { + { + let mut transcript = self.transcript.lock().expect("mutex sain"); + transcript.push(json!({"role": "user", "content": prompt})); + } + + let mut events = vec![ReplyEvent::Heartbeat]; + send_tap(&tap, &ReplyEvent::Heartbeat); + for iteration in 0..=self.config.effective_max_tool_iterations() { + let transcript = self.transcript.lock().expect("mutex sain").clone(); + let tools = self.effective_tools(); + let response = self.post_chat(&transcript, &tools, true).await; + let response = match response { + Ok(response) => response, + Err(err) => { + if should_retry_without_tools(&err, !tools.is_empty()) { + self.disable_tools_once(&mut events, &tap); + let transcript = self.transcript.lock().expect("mutex sain").clone(); + self.post_chat(&transcript, &[], true).await? + } else { + return Err(err); + } + } + }; + + let turn = self.consume_response(response, &tap).await?; + events.extend(turn.events); + + if !turn.tool_calls.is_empty() { + if iteration >= self.config.effective_max_tool_iterations() { + let content = "Tool-calling stopped: max_tool_iterations reached.".to_owned(); + self.transcript + .lock() + .expect("mutex sain") + .push(json!({"role": "assistant", "content": content})); + events.push(ReplyEvent::Final { content }); + self.persist_transcript()?; + return Ok(Box::new(events.into_iter())); + } + self.apply_tool_calls(&turn.tool_calls, &mut events, &tap) + .await; + self.persist_transcript()?; + continue; + } + + self.transcript + .lock() + .expect("mutex sain") + .push(json!({"role": "assistant", "content": turn.content})); + events.push(ReplyEvent::Final { + content: turn.content, + }); + self.persist_transcript()?; + return Ok(Box::new(events.into_iter())); + } + unreachable!("loop returns at max_tool_iterations"); + } + + fn effective_tools(&self) -> Vec { + if *self.tools_disabled.lock().expect("mutex sain") { + return Vec::new(); + } + self.tool_invoker + .as_ref() + .map_or_else(Vec::new, |invoker| invoker.tools()) + } + + async fn post_chat( + &self, + messages: &[Value], + tools: &[ToolSpec], + stream: bool, + ) -> Result { + let mut body = json!({ + "model": self.config.model, + "messages": messages, + "stream": stream, + }); + if !tools.is_empty() { + body["tools"] = Value::Array(tools.iter().map(tool_spec_to_openai).collect()); + } + let mut request = self.client.post(&self.endpoint).json(&body); + if let Some(key) = &self.api_key { + request = request.bearer_auth(key); + } + let response = request + .send() + .await + .map_err(|e| self.map_reqwest_error(e))?; + if response.status().is_success() { + *self.first_contact.lock().expect("mutex sain") = false; + return Ok(response); + } + let status = response.status(); + if status == StatusCode::BAD_REQUEST + || status == StatusCode::NOT_FOUND + || status == StatusCode::UNPROCESSABLE_ENTITY + { + return Err(AgentSessionError::Start(format!( + "endpoint OpenAI-compatible rejected request with HTTP {status}" + ))); + } + Err(AgentSessionError::Io(format!( + "endpoint OpenAI-compatible returned HTTP {status}" + ))) + } + + async fn consume_response( + &self, + response: reqwest::Response, + tap: &Option>, + ) -> Result { + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_ascii_lowercase(); + if !content_type.contains("text/event-stream") { + let body = response + .text() + .await + .map_err(|e| AgentSessionError::Io(format!("lecture réponse HTTP: {e}")))?; + let parsed = parse_completion(&body)?; + return Ok(TurnResult { + events: text_events(&parsed.content, tap), + content: parsed.content, + tool_calls: parsed.tool_calls, + }); + } + + let mut state = StreamState::default(); + let mut events = Vec::new(); + let mut pending = String::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = + chunk.map_err(|e| AgentSessionError::Io(format!("lecture flux SSE: {e}")))?; + pending.push_str(&String::from_utf8_lossy(&chunk)); + while let Some(pos) = pending.find('\n') { + let line = pending[..pos].trim_end_matches('\r').to_owned(); + pending = pending[pos + 1..].to_owned(); + handle_sse_line(&line, &mut state, &mut events, tap)?; + } + } + if !pending.trim().is_empty() { + handle_sse_line(pending.trim_end_matches('\r'), &mut state, &mut events, tap)?; + } + Ok(TurnResult { + events, + content: state.content, + tool_calls: state + .tool_calls + .into_iter() + .filter_map(ToolCallBuilder::finish) + .collect(), + }) + } + + async fn apply_tool_calls( + &self, + tool_calls: &[OpenAiToolCall], + events: &mut Vec, + tap: &Option>, + ) { + let assistant_calls = tool_calls + .iter() + .map(|call| { + json!({ + "id": call.id, + "type": "function", + "function": { "name": call.name, "arguments": call.arguments } + }) + }) + .collect::>(); + self.transcript.lock().expect("mutex sain").push(json!({ + "role": "assistant", + "content": Value::Null, + "tool_calls": assistant_calls, + })); + + for call in tool_calls { + let event = ReplyEvent::ToolActivity { + label: call.name.clone(), + }; + send_tap(tap, &event); + events.push(event); + let result = match &self.tool_invoker { + Some(invoker) => invoker + .call(&call.name, &call.arguments) + .await + .unwrap_or_else(|e| format!("Tool invocation failed: {e}")), + None => "Tool invocation unavailable".to_owned(), + }; + self.transcript.lock().expect("mutex sain").push(json!({ + "role": "tool", + "tool_call_id": call.id, + "content": result, + })); + } + } + + fn disable_tools_once( + &self, + events: &mut Vec, + tap: &Option>, + ) { + *self.tools_disabled.lock().expect("mutex sain") = true; + let mut reported = self.tools_rejection_reported.lock().expect("mutex sain"); + if !*reported { + let event = ReplyEvent::Announcement { + text: "Tool calling disabled for this OpenAI-compatible endpoint; retrying chat without tools.".to_owned(), + }; + send_tap(tap, &event); + events.push(event); + *reported = true; + } + } + + fn map_reqwest_error(&self, e: reqwest::Error) -> AgentSessionError { + if *self.first_contact.lock().expect("mutex sain") && (e.is_connect() || e.is_request()) { + return AgentSessionError::Start("endpoint OpenAI-compatible indisponible".to_owned()); + } + if e.is_timeout() { + return AgentSessionError::Io(format!("communication endpoint OpenAI-compatible: {e}")); + } + AgentSessionError::Io(format!("communication endpoint OpenAI-compatible: {e}")) + } + + fn persist_transcript(&self) -> Result<(), AgentSessionError> { + if let Some(parent) = self.transcript_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| AgentSessionError::Io(format!("création run dir: {e}")))?; + } + let transcript = self.transcript.lock().expect("mutex sain"); + let bytes = serde_json::to_vec_pretty(&*transcript) + .map_err(|e| AgentSessionError::Decode(format!("transcript JSON invalide: {e}")))?; + std::fs::write(&self.transcript_path, bytes) + .map_err(|e| AgentSessionError::Io(format!("écriture transcript: {e}"))) + } +} + +#[async_trait] +impl AgentSession for OpenAiCompatibleSession { + fn id(&self) -> SessionId { + self.id + } + + fn conversation_id(&self) -> Option { + None + } + + async fn send(&self, prompt: &str) -> Result { + self.send_inner(prompt, None).await + } + + async fn send_with_tap( + &self, + prompt: &str, + tap: std::sync::mpsc::Sender, + ) -> Result { + self.send_inner(prompt, Some(tap)).await + } + + async fn shutdown(&self) -> Result<(), AgentSessionError> { + Ok(()) + } +} + +#[derive(Debug)] +struct TurnResult { + events: Vec, + content: String, + tool_calls: Vec, +} + +fn handle_sse_line( + line: &str, + state: &mut StreamState, + events: &mut Vec, + tap: &Option>, +) -> Result<(), AgentSessionError> { + let Some(data) = line.strip_prefix("data:") else { + return Ok(()); + }; + let data = data.trim(); + if data == "[DONE]" { + return Ok(()); + } + let parsed = parse_chat_delta(data)?; + if !parsed.text.is_empty() { + state.content.push_str(&parsed.text); + let event = ReplyEvent::TextDelta { text: parsed.text }; + send_tap(tap, &event); + events.push(event); + } + merge_tool_call_deltas(state, parsed.tool_calls); + Ok(()) +} + +fn merge_tool_call_deltas(state: &mut StreamState, calls: Vec) { + for (idx, call) in calls.into_iter().enumerate() { + if state.tool_calls.len() <= idx { + state + .tool_calls + .resize_with(idx + 1, ToolCallBuilder::default); + } + let target = &mut state.tool_calls[idx]; + if !call.id.is_empty() { + target.id = call.id; + } + if !call.name.is_empty() { + target.name = call.name; + } + target.arguments.push_str(&call.arguments); + } +} + +fn text_events( + content: &str, + tap: &Option>, +) -> Vec { + if content.is_empty() { + return Vec::new(); + } + let event = ReplyEvent::TextDelta { + text: content.to_owned(), + }; + send_tap(tap, &event); + vec![event] +} + +fn send_tap(tap: &Option>, event: &ReplyEvent) { + if let Some(tap) = tap { + let _ = tap.send(event.clone()); + } +} + +fn should_retry_without_tools(err: &AgentSessionError, using_tools: bool) -> bool { + using_tools && matches!(err, AgentSessionError::Start(_)) +} + +fn tool_spec_to_openai(spec: &ToolSpec) -> Value { + json!({ + "type": "function", + "function": { + "name": spec.name, + "description": spec.description, + "parameters": spec.input_schema, + } + }) +} + +fn chat_completions_endpoint(endpoint: &str) -> String { + let trimmed = endpoint.trim_end_matches('/'); + if trimmed.ends_with("/chat/completions") { + trimmed.to_owned() + } else { + format!("{trimmed}/chat/completions") + } +} + +fn load_or_seed_transcript( + path: &Path, + system_context: &str, +) -> Result, AgentSessionError> { + match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|e| AgentSessionError::Decode(format!("transcript JSON illisible: {e}"))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![json!({ + "role": "system", + "content": system_context, + })]), + Err(e) => Err(AgentSessionError::Io(format!("lecture transcript: {e}"))), + } +} + +#[allow(dead_code)] +fn _assert_adapter_name(adapter: StructuredAdapter) -> &'static str { + adapter.provider_key() +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct _SerdeGuard; + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + use super::*; + use domain::ports::ToolInvocationError; + + #[derive(Default)] + struct FakeInvoker { + calls: AtomicUsize, + } + + #[async_trait] + impl ToolInvoker for FakeInvoker { + fn tools(&self) -> Vec { + vec![ToolSpec { + name: "idea_echo".to_owned(), + description: "Echo".to_owned(), + input_schema: json!({"type":"object"}), + }] + } + + async fn call(&self, name: &str, args_json: &str) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(format!("{name}:{args_json}")) + } + } + + enum TestHttpResponse { + OkJson(&'static str), + Status { code: u16, body: &'static str }, + Hang, + } + + impl TestHttpResponse { + fn ok(body: &'static str) -> Self { + Self::OkJson(body) + } + } + + async fn http_server( + responses: Vec, + ) -> (String, Arc>>, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + let bodies = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&bodies); + let responses = Arc::new(Mutex::new(responses.into_iter())); + let handle = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 1024]; + loop { + let n = socket.read(&mut chunk).await.expect("read request"); + if n == 0 { + return; + } + buffer.extend_from_slice(&chunk[..n]); + if let Some(headers_end) = find_headers_end(&buffer) { + let headers = String::from_utf8_lossy(&buffer[..headers_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|value| value.trim().parse::().ok()) + }) + .unwrap_or(0); + let total = headers_end + 4 + content_length; + while buffer.len() < total { + let n = socket.read(&mut chunk).await.expect("read body"); + if n == 0 { + return; + } + buffer.extend_from_slice(&chunk[..n]); + } + let body = + String::from_utf8_lossy(&buffer[headers_end + 4..total]).to_string(); + captured.lock().expect("mutex sain").push(body); + break; + } + } + let body = responses + .lock() + .expect("mutex sain") + .next() + .unwrap_or_else(|| { + TestHttpResponse::OkJson( + r#"{"choices":[{"message":{"role":"assistant","content":"done"}}]}"#, + ) + }); + match body { + TestHttpResponse::OkJson(body) => { + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + socket + .write_all(response.as_bytes()) + .await + .expect("write response"); + } + TestHttpResponse::Status { code, body } => { + let response = format!( + "HTTP/1.1 {code} test\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + socket + .write_all(response.as_bytes()) + .await + .expect("write response"); + } + TestHttpResponse::Hang => { + tokio::time::sleep(Duration::from_secs(60)).await; + } + } + } + }); + (format!("http://{addr}/v1"), bodies, handle) + } + + fn find_headers_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") + } + + fn temp_run_dir(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "idea-openai-compat-{name}-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&path).expect("temp run dir"); + path + } + + fn config(endpoint: String, max_tool_iterations: Option) -> HttpChatConfig { + config_with_timeouts(endpoint, max_tool_iterations, 10_000, 1_000) + } + + fn config_with_timeouts( + endpoint: String, + max_tool_iterations: Option, + request_timeout_ms: u32, + connect_timeout_ms: u32, + ) -> HttpChatConfig { + HttpChatConfig::new( + endpoint, + "local-model", + None, + Some(request_timeout_ms), + Some(connect_timeout_ms), + max_tool_iterations, + ) + .expect("valid config") + } + + async fn drain(session: &OpenAiCompatibleSession, prompt: &str) -> Vec { + session + .send(prompt) + .await + .expect("send") + .collect::>() + } + + #[test] + fn parse_openai_sse_delta_text() { + let parsed = parse_chat_delta( + r#"{"choices":[{"delta":{"content":"bonjour"},"finish_reason":null}]}"#, + ) + .expect("parse ok"); + assert_eq!(parsed.text, "bonjour"); + assert!(parsed.tool_calls.is_empty()); + } + + #[test] + fn parse_ollama_completion_message() { + let parsed = + parse_completion(r#"{"choices":[{"message":{"role":"assistant","content":"salut"}}]}"#) + .expect("parse ok"); + assert_eq!(parsed.content, "salut"); + } + + #[test] + fn parse_completion_tool_calls() { + let parsed = parse_completion( + r#"{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"idea_ask_agent","arguments":"{\"target\":\"QA\"}"}}]}}]}"#, + ) + .expect("parse ok"); + assert_eq!( + parsed.tool_calls, + vec![OpenAiToolCall { + id: "call_1".to_owned(), + name: "idea_ask_agent".to_owned(), + arguments: "{\"target\":\"QA\"}".to_owned(), + }] + ); + } + + #[test] + fn parse_broken_json_does_not_leak_payload() { + let err = parse_chat_delta("{ not json").expect_err("decode error"); + match err { + AgentSessionError::Decode(msg) => assert!(!msg.contains("not json")), + other => panic!("expected Decode, got {other:?}"), + } + } + + #[test] + fn endpoint_appends_chat_completions_once() { + assert_eq!( + chat_completions_endpoint("http://localhost:11434/v1"), + "http://localhost:11434/v1/chat/completions" + ); + assert_eq!( + chat_completions_endpoint("http://x/v1/chat/completions"), + "http://x/v1/chat/completions" + ); + } + + #[tokio::test] + async fn non_streaming_completion_persists_and_reloads_transcript() { + let (endpoint, bodies, handle) = http_server(vec![ + TestHttpResponse::ok( + r#"{"choices":[{"message":{"role":"assistant","content":"one"}}]}"#, + ), + TestHttpResponse::ok( + r#"{"choices":[{"message":{"role":"assistant","content":"two"}}]}"#, + ), + ]) + .await; + let run_dir = temp_run_dir("persist"); + let session = OpenAiCompatibleSession::new( + SessionId::new_random(), + config(endpoint.clone(), None), + &run_dir, + "# system", + None, + ) + .expect("session"); + let events = drain(&session, "hello").await; + assert!(matches!(events.last(), Some(ReplyEvent::Final { content }) if content == "one")); + assert!(run_dir.join(TRANSCRIPT_FILE).exists()); + + let reloaded = OpenAiCompatibleSession::new( + SessionId::new_random(), + config(endpoint, None), + &run_dir, + "# ignored", + None, + ) + .expect("reloaded"); + let _ = drain(&reloaded, "again").await; + let bodies = bodies.lock().expect("mutex sain"); + assert!( + bodies[1].contains("one"), + "second request should include persisted assistant transcript: {}", + bodies[1] + ); + handle.abort(); + } + + #[tokio::test] + async fn tool_loop_invokes_tool_and_reposts_result() { + let (endpoint, bodies, handle) = http_server(vec![ + TestHttpResponse::ok( + r#"{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"idea_echo","arguments":"{\"x\":1}"}}]}}]}"#, + ), + TestHttpResponse::ok( + r#"{"choices":[{"message":{"role":"assistant","content":"done"}}]}"#, + ), + ]) + .await; + let invoker = Arc::new(FakeInvoker::default()); + let session = OpenAiCompatibleSession::new( + SessionId::new_random(), + config(endpoint, Some(4)), + temp_run_dir("tools"), + "# system", + Some(invoker.clone()), + ) + .expect("session"); + let events = drain(&session, "use tool").await; + assert_eq!(invoker.calls.load(Ordering::SeqCst), 1); + assert!(events.iter().any(|event| matches!( + event, + ReplyEvent::ToolActivity { label } if label == "idea_echo" + ))); + assert!(matches!(events.last(), Some(ReplyEvent::Final { content }) if content == "done")); + let bodies = bodies.lock().expect("mutex sain"); + assert_eq!(bodies.len(), 2); + assert!(bodies[1].contains("\"role\":\"tool\"")); + handle.abort(); + } + + #[tokio::test] + async fn max_tool_iterations_returns_single_degraded_final() { + let tool = r#"{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"idea_echo","arguments":"{}"}}]}}]}"#; + let (endpoint, _bodies, handle) = + http_server(vec![TestHttpResponse::ok(tool), TestHttpResponse::ok(tool)]).await; + let session = OpenAiCompatibleSession::new( + SessionId::new_random(), + config(endpoint, Some(1)), + temp_run_dir("limit"), + "# system", + Some(Arc::new(FakeInvoker::default())), + ) + .expect("session"); + let events = drain(&session, "loop").await; + let finals = events + .iter() + .filter(|event| matches!(event, ReplyEvent::Final { .. })) + .count(); + assert_eq!(finals, 1); + assert!(matches!( + events.last(), + Some(ReplyEvent::Final { content }) if content.contains("max_tool_iterations") + )); + handle.abort(); + } + + #[tokio::test] + async fn normal_completion_returns_exactly_one_terminal_final() { + let (endpoint, _bodies, handle) = http_server(vec![TestHttpResponse::ok( + r#"{"choices":[{"message":{"role":"assistant","content":"ok"}}]}"#, + )]) + .await; + let session = OpenAiCompatibleSession::new( + SessionId::new_random(), + config(endpoint, None), + temp_run_dir("single-final"), + "# system", + None, + ) + .expect("session"); + let events = drain(&session, "hello").await; + let final_positions: Vec = events + .iter() + .enumerate() + .filter_map(|(idx, event)| matches!(event, ReplyEvent::Final { .. }).then_some(idx)) + .collect(); + assert_eq!(final_positions, vec![events.len() - 1]); + handle.abort(); + } + + #[tokio::test] + async fn openai_compatible_conversation_id_is_none() { + let (endpoint, _bodies, handle) = http_server(vec![TestHttpResponse::ok( + r#"{"choices":[{"message":{"role":"assistant","content":"ok"}}]}"#, + )]) + .await; + let session = OpenAiCompatibleSession::new( + SessionId::new_random(), + config(endpoint, None), + temp_run_dir("conversation-id-none"), + "# system", + None, + ) + .expect("session"); + assert_eq!(session.conversation_id(), None); + let _ = drain(&session, "hello").await; + assert_eq!(session.conversation_id(), None); + handle.abort(); + } + + #[tokio::test] + async fn unreachable_endpoint_on_first_contact_is_start_error() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + let session = OpenAiCompatibleSession::new( + SessionId::new_random(), + config_with_timeouts(format!("http://{addr}/v1"), None, 500, 100), + temp_run_dir("unreachable"), + "# system", + None, + ) + .expect("session"); + match session.send("hello").await { + Err(AgentSessionError::Start(msg)) => { + assert!(msg.contains("indisponible"), "unexpected message: {msg}"); + } + Err(other) => panic!("expected Start, got {other:?}"), + Ok(_) => panic!("expected first contact failure"), + } + } + + #[tokio::test] + async fn timeout_after_successful_contact_is_io_error() { + let (endpoint, _bodies, handle) = http_server(vec![ + TestHttpResponse::ok( + r#"{"choices":[{"message":{"role":"assistant","content":"one"}}]}"#, + ), + TestHttpResponse::Hang, + ]) + .await; + let session = OpenAiCompatibleSession::new( + SessionId::new_random(), + config_with_timeouts(endpoint, None, 100, 100), + temp_run_dir("mid-turn-timeout"), + "# system", + None, + ) + .expect("session"); + let _ = drain(&session, "first").await; + match session.send("second").await { + Err(AgentSessionError::Io(msg)) => { + assert!( + msg.contains("communication endpoint OpenAI-compatible"), + "unexpected message: {msg}" + ); + } + Err(other) => panic!("expected Io, got {other:?}"), + Ok(_) => panic!("expected timeout after first contact"), + } + handle.abort(); + } + + #[tokio::test] + async fn http_400_404_422_map_to_start_without_body_leak() { + for status in [400, 404, 422] { + let secret_body = r#"{"error":"MODEL_SECRET_SHOULD_NOT_LEAK"}"#; + let (endpoint, _bodies, handle) = http_server(vec![TestHttpResponse::Status { + code: status, + body: secret_body, + }]) + .await; + let session = OpenAiCompatibleSession::new( + SessionId::new_random(), + config(endpoint, None), + temp_run_dir(&format!("status-{status}")), + "# system", + None, + ) + .expect("session"); + match session.send("hello").await { + Err(AgentSessionError::Start(msg)) => { + assert!(msg.contains(&format!("HTTP {status}"))); + assert!( + !msg.contains("MODEL_SECRET_SHOULD_NOT_LEAK") && !msg.contains("error"), + "raw HTTP body must not leak in error message: {msg}" + ); + } + Err(other) => panic!("expected Start for HTTP {status}, got {other:?}"), + Ok(_) => panic!("expected HTTP {status} error"), + } + handle.abort(); + } + } + + #[tokio::test] + async fn tool_rejection_disables_tools_retries_once_and_keeps_conversing() { + let (endpoint, bodies, handle) = http_server(vec![ + TestHttpResponse::Status { + code: 400, + body: r#"{"error":"tools unsupported"}"#, + }, + TestHttpResponse::ok( + r#"{"choices":[{"message":{"role":"assistant","content":"chat nu"}}]}"#, + ), + TestHttpResponse::ok( + r#"{"choices":[{"message":{"role":"assistant","content":"encore"}}]}"#, + ), + ]) + .await; + let session = OpenAiCompatibleSession::new( + SessionId::new_random(), + config(endpoint, None), + temp_run_dir("tools-rejected"), + "# system", + Some(Arc::new(FakeInvoker::default())), + ) + .expect("session"); + + let first = drain(&session, "hello").await; + assert_eq!( + first + .iter() + .filter(|event| matches!(event, ReplyEvent::Announcement { .. })) + .count(), + 1, + "tool rejection should announce the degradation exactly once" + ); + assert!(matches!( + first.last(), + Some(ReplyEvent::Final { content }) if content == "chat nu" + )); + + let second = drain(&session, "again").await; + assert_eq!( + second + .iter() + .filter(|event| matches!(event, ReplyEvent::Announcement { .. })) + .count(), + 0, + "tools stay disabled; no repeated degradation announcement" + ); + assert!(matches!( + second.last(), + Some(ReplyEvent::Final { content }) if content == "encore" + )); + + let bodies = bodies.lock().expect("mutex sain"); + assert_eq!(bodies.len(), 3); + assert!(bodies[0].contains("\"tools\""), "first request uses tools"); + assert!( + !bodies[1].contains("\"tools\"") && !bodies[2].contains("\"tools\""), + "retried and later requests must be plain chat: {bodies:?}" + ); + handle.abort(); + } +} From d89380cdf0fdbb06cb02a8298038428ed41d5434 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 7 Jul 2026 22:34:50 +0200 Subject: [PATCH 2/2] feat(ui): profils locaux/LAN OpenAI-compatible dans le first-run et les terminaux (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Câble la surface frontend des profils IA locaux/LAN OpenAI-compatible, en parité avec l'adapter backend additif (aab4bca). - domain: types de profil OpenAI-compatible - first-run: édition/validation du profil dans le FirstRunWizard - adapters/mock: mock de profil pour les tests - terminals: rendu des round-trips et erreurs endpoint (role=alert) Validé QA (frontend GO): typecheck exit 0, vitest 59 fichiers / 566 tests, 0 echec ; couverture timeouts round-trip + erreur endpoint role=alert prouvees ; pas de regression sur les tests de bail headless. Co-Authored-By: Claude Opus 4.8 --- frontend/src/adapters/mock/index.ts | 17 +++ frontend/src/adapters/mock/profile.test.ts | 7 +- frontend/src/domain/index.ts | 49 +++++++ .../first-run/FirstRunWizard.test.tsx | 128 +++++++++++++++++ .../src/features/first-run/FirstRunWizard.tsx | 136 +++++++++++++++++- .../src/features/first-run/profile.test.ts | 96 +++++++++++++ frontend/src/features/first-run/profile.ts | 94 +++++++++++- .../features/terminals/TerminalView.test.tsx | 87 ++++++++++- .../src/features/terminals/TerminalView.tsx | 52 ++++++- 9 files changed, 656 insertions(+), 10 deletions(-) diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 3132b47..6134dac 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1159,6 +1159,23 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [ detect: "codex --version", cwdTemplate: "{projectRoot}", }, + { + id: "mock-ollama", + name: "Ollama / OpenAI-compatible local model", + command: "openai-compatible", + args: [], + contextInjection: { strategy: "conventionFile", target: "AGENTS.md" }, + detect: null, + cwdTemplate: "{projectRoot}", + structuredAdapter: "openAiCompatible", + chatHttp: { + endpoint: "http://localhost:11434/v1", + model: "qwen2.5-coder", + requestTimeoutMs: 120_000, + connectTimeoutMs: 5_000, + maxToolIterations: 16, + }, + }, ]; /** diff --git a/frontend/src/adapters/mock/profile.test.ts b/frontend/src/adapters/mock/profile.test.ts index ce15288..6d2c361 100644 --- a/frontend/src/adapters/mock/profile.test.ts +++ b/frontend/src/adapters/mock/profile.test.ts @@ -22,14 +22,16 @@ function customProfile(id: string, command: string): AgentProfile { describe("MockProfileGateway", () => { it("firstRunState is first-run with only the selectable reference profiles", async () => { - // §17.3/D7: only structured-drivable profiles (Claude/Codex) are offered; - // Gemini/Aider are filtered out of the selection path server-side. + // §17.3/D7: only structured-drivable profiles are offered (Claude/Codex CLIs + // + the OpenAI-compatible local/LAN adapter, ticket #14); Gemini/Aider are + // filtered out of the selection path server-side. const gw = new MockProfileGateway(); const state = await gw.firstRunState(); expect(state.isFirstRun).toBe(true); expect(state.referenceProfiles.map((p) => p.command)).toEqual([ "claude", "codex", + "openai-compatible", ]); }); @@ -51,6 +53,7 @@ describe("MockProfileGateway", () => { expect(byCommand).toEqual({ claude: true, codex: false, + "openai-compatible": false, }); }); diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index da32256..d4a5945 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -626,6 +626,42 @@ export type ContextInjection = /** The four injection strategy discriminants. */ export type InjectionStrategy = ContextInjection["strategy"]; +/** + * Structured-execution adapter of a profile (mirror of the backend + * `StructuredAdapter`, camelCase wire format). Absent (`structuredAdapter` + * omitted) ⇒ the profile is a plain TUI/PTY agent (historical behaviour); + * present ⇒ the profile is selectable as a structured AI agent: + * - `claude` / `codex`: driven by a spawned CLI binary, + * - `openAiCompatible`: driven by IdeA's native HTTP adapter against an + * OpenAI-compatible chat server (Ollama, llama.cpp, a LAN runtime) — see + * {@link HttpChatConfig}. + */ +export type StructuredAdapter = "claude" | "codex" | "openAiCompatible"; + +/** + * HTTP configuration of an OpenAI-compatible chat server (mirror of the backend + * `HttpChatConfig`, camelCase wire format). Carried by a profile whose + * {@link StructuredAdapter} is `openAiCompatible`. + * + * Transparent by design: secrets are never stored here — `apiKeyEnv` is the + * *name* of an environment variable, never the key itself (the backend resolves + * it at call time). + */ +export interface HttpChatConfig { + /** Root or `/chat/completions` endpoint of the HTTP server (http/https). */ + endpoint: string; + /** Model name sent in the `/chat/completions` payload. */ + model: string; + /** Name of the env var holding the API key (optional). Never the key. */ + apiKeyEnv?: string; + /** Per-turn request timeout, in milliseconds. */ + requestTimeoutMs?: number; + /** Connection timeout, in milliseconds. */ + connectTimeoutMs?: number; + /** Tool-calling re-loop guard for one turn (defaults to ~16 backend-side). */ + maxToolIterations?: number; +} + /** * A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a * UUID string; `detect` is the optional detection command line. @@ -641,6 +677,19 @@ export interface AgentProfile { /** Optional CLI flags for agent-session continuity: `assignFlag` to bind a * conversation id at launch, `resumeFlag` to resume an existing conversation. */ session?: { assignFlag?: string; resumeFlag: string }; + /** + * Structured-execution adapter (mirror of the backend `structured_adapter`, + * `skip_serializing_if = Option::is_none`). Absent ⇒ plain TUI/PTY profile; + * present ⇒ selectable structured AI agent. Additive: existing Claude/Codex + * profiles are unaffected. + */ + structuredAdapter?: StructuredAdapter; + /** + * HTTP config for a `openAiCompatible` structured adapter (mirror of the + * backend `chat_http`, `skip_serializing_if = Option::is_none`). Absent for + * every historical profile and every non-HTTP adapter. + */ + chatHttp?: HttpChatConfig; } /** Availability of a candidate profile after detection (mirror of the DTO). */ diff --git a/frontend/src/features/first-run/FirstRunWizard.test.tsx b/frontend/src/features/first-run/FirstRunWizard.test.tsx index 56a6998..03caf1a 100644 --- a/frontend/src/features/first-run/FirstRunWizard.test.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.test.tsx @@ -173,6 +173,134 @@ describe("FirstRunWizard (with MockProfileGateway)", () => { }); }); +describe("FirstRunWizard — OpenAI-compatible local/LAN profile (ticket #14)", () => { + const OLLAMA = "Ollama / OpenAI-compatible local model"; + + it("offers the local model as a selectable, pre-filled HTTP config row", async () => { + renderWizard(); + await waitForLoaded(); + + expect(screen.getByText(OLLAMA)).toBeTruthy(); + // The structured HTTP fields are rendered, pre-filled from the reference. + expect( + (screen.getByLabelText(`${OLLAMA} endpoint`) as HTMLInputElement).value, + ).toBe("http://localhost:11434/v1"); + expect( + (screen.getByLabelText(`${OLLAMA} model`) as HTMLInputElement).value, + ).toBe("qwen2.5-coder"); + // Claude/Codex rows must NOT grow HTTP fields (additive, no regression). + expect(screen.queryByLabelText("Claude Code endpoint")).toBeNull(); + }); + + it("labels the API key field as an env var NAME and flags a raw key", async () => { + renderWizard(); + await waitForLoaded(); + + const apiKey = screen.getByLabelText(`${OLLAMA} api key env`) as HTMLInputElement; + // The label/placeholder make the env-var-name intent explicit. + expect(apiKey.placeholder.toLowerCase()).toContain("variable name"); + + // A raw secret is not a valid env var identifier ⇒ inline error. + fireEvent.change(apiKey, { target: { value: "sk-secret-123" } }); + expect( + screen.getByText(/valid env var name \(not the key itself\)/i), + ).toBeTruthy(); + + // A proper env var name clears the error. + fireEvent.change(apiKey, { target: { value: "OPENAI_API_KEY" } }); + expect( + screen.queryByText(/valid env var name \(not the key itself\)/i), + ).toBeNull(); + }); + + it("flags an invalid endpoint scheme inline", async () => { + renderWizard(); + await waitForLoaded(); + + const endpoint = screen.getByLabelText(`${OLLAMA} endpoint`); + fireEvent.change(endpoint, { target: { value: "ftp://oops" } }); + expect(screen.getByText(/must start with http:\/\/ or https:\/\//i)).toBeTruthy(); + }); + + it("exposes the timeout / tool-guard fields pre-filled from the reference", async () => { + renderWizard(); + await waitForLoaded(); + + expect( + (screen.getByLabelText(`${OLLAMA} request timeout`) as HTMLInputElement).value, + ).toBe("120000"); + expect( + (screen.getByLabelText(`${OLLAMA} connect timeout`) as HTMLInputElement).value, + ).toBe("5000"); + expect( + (screen.getByLabelText(`${OLLAMA} max tool iterations`) as HTMLInputElement) + .value, + ).toBe("16"); + }); + + it("persists edited timeouts / tool-guard round-trip into chatHttp", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + + fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`)); + fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), { + target: { value: "90000" }, + }); + fireEvent.change(screen.getByLabelText(`${OLLAMA} connect timeout`), { + target: { value: "3000" }, + }); + fireEvent.change(screen.getByLabelText(`${OLLAMA} max tool iterations`), { + target: { value: "8" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + const ollama = saved.find((p) => p.command === "openai-compatible"); + expect(ollama?.chatHttp?.requestTimeoutMs).toBe(90000); + expect(ollama?.chatHttp?.connectTimeoutMs).toBe(3000); + expect(ollama?.chatHttp?.maxToolIterations).toBe(8); + }); + }); + + it("flags a zero timeout inline (mirror of the backend guard)", async () => { + renderWizard(); + await waitForLoaded(); + + fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), { + target: { value: "0" }, + }); + expect(screen.getByText(/positive integer \(ms\)/i)).toBeTruthy(); + }); + + it("persists the edited HTTP config round-trip when selected and saved", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + + // Select the local model and edit its model name. + fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`)); + fireEvent.change(screen.getByLabelText(`${OLLAMA} model`), { + target: { value: "qwen3" }, + }); + fireEvent.change(screen.getByLabelText(`${OLLAMA} api key env`), { + target: { value: "LAN_KEY" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + const ollama = saved.find((p) => p.command === "openai-compatible"); + expect(ollama?.structuredAdapter).toBe("openAiCompatible"); + expect(ollama?.chatHttp?.model).toBe("qwen3"); + expect(ollama?.chatHttp?.apiKeyEnv).toBe("LAN_KEY"); + // The endpoint round-trips untouched. + expect(ollama?.chatHttp?.endpoint).toBe("http://localhost:11434/v1"); + }); + }); +}); + describe("FirstRunWizard reopening after the first run (forceOpen)", () => { /** A gateway whose first run is already done (profiles configured). */ async function configuredGateway() { diff --git a/frontend/src/features/first-run/FirstRunWizard.tsx b/frontend/src/features/first-run/FirstRunWizard.tsx index 3c5e274..1476ed0 100644 --- a/frontend/src/features/first-run/FirstRunWizard.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.tsx @@ -15,10 +15,15 @@ * `./profile`. */ -import type { AgentProfile } from "@/domain"; +import type { AgentProfile, HttpChatConfig } from "@/domain"; import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared"; import { useFirstRun, type WizardEntry } from "./useFirstRun"; -import { parseArgs, validateProfile } from "./profile"; +import { + defaultHttpChatConfig, + parseArgs, + validateProfile, + type ProfileErrors, +} from "./profile"; /** A small caption above a control. */ function Caption({ children }: { children: React.ReactNode }) { @@ -174,6 +179,133 @@ function ProfileRow({ onChange={(e) => onChange({ ...profile, args: parseArgs(e.target.value) })} /> + + {profile.structuredAdapter === "openAiCompatible" && ( + + )} ); } + +/** Parses a number field: blank ⇒ `undefined` (backend applies its default). */ +function parseOptInt(raw: string): number | undefined { + const trimmed = raw.trim(); + if (trimmed.length === 0) return undefined; + const n = Number(trimmed); + return Number.isFinite(n) ? n : NaN; +} + +/** + * The OpenAI-compatible (local/LAN) HTTP config section: endpoint, model, the + * *name* of the API-key env var (never the key), and optional timeouts / tool + * guard. Rendered only for a `openAiCompatible` profile; edits flow back through + * `onChange` as a patched `chatHttp`. + */ +function HttpChatFields({ + profile, + errors, + onChange, +}: { + profile: AgentProfile; + errors: ProfileErrors; + onChange: (p: AgentProfile) => void; +}) { + const http = profile.chatHttp ?? defaultHttpChatConfig(); + const patch = (next: Partial) => + onChange({ ...profile, chatHttp: { ...http, ...next } }); + + return ( +
+ + Local / LAN model (OpenAI-compatible) + + + + + + + + +
+ + + + + +
+
+ ); +} diff --git a/frontend/src/features/first-run/profile.test.ts b/frontend/src/features/first-run/profile.test.ts index aaee781..cc1c91c 100644 --- a/frontend/src/features/first-run/profile.test.ts +++ b/frontend/src/features/first-run/profile.test.ts @@ -7,12 +7,15 @@ import { describe, it, expect } from "vitest"; import type { AgentProfile } from "@/domain"; import { + defaultHttpChatConfig, defaultInjection, emptyCustomProfile, isProfileValid, isRelativeSafe, isValidEnvVar, + isValidHttpUrl, parseArgs, + validateHttpChatConfig, validateProfile, } from "./profile"; @@ -93,6 +96,99 @@ describe("validateProfile / isProfileValid", () => { }); }); +describe("isValidHttpUrl", () => { + it("accepts http/https endpoints", () => { + expect(isValidHttpUrl("http://localhost:11434/v1")).toBe(true); + expect(isValidHttpUrl("https://lan.host:8080/v1")).toBe(true); + }); + it("rejects empty, non-http and other schemes", () => { + expect(isValidHttpUrl("")).toBe(false); + expect(isValidHttpUrl(" ")).toBe(false); + expect(isValidHttpUrl("ftp://host")).toBe(false); + expect(isValidHttpUrl("localhost:11434")).toBe(false); + // Leading whitespace fails the raw prefix check, mirroring the backend. + expect(isValidHttpUrl(" http://host")).toBe(false); + }); +}); + +describe("validateHttpChatConfig", () => { + it("a well-formed local model config is valid", () => { + expect(validateHttpChatConfig(defaultHttpChatConfig())).toEqual({}); + }); + it("reports a bad endpoint scheme and an empty model", () => { + const errors = validateHttpChatConfig({ endpoint: "ftp://x", model: " " }); + expect(errors.endpoint).toBeDefined(); + expect(errors.model).toBeDefined(); + }); + it("apiKeyEnv must be an env var NAME, never a raw key", () => { + // A plausible raw key contains characters illegal in an env var identifier. + const withKey = validateHttpChatConfig({ + endpoint: "http://host", + model: "m", + apiKeyEnv: "sk-abc123", + }); + expect(withKey.apiKeyEnv).toBeDefined(); + // A valid identifier passes; blank/undefined is allowed (optional). + expect( + validateHttpChatConfig({ + endpoint: "http://host", + model: "m", + apiKeyEnv: "OPENAI_API_KEY", + }).apiKeyEnv, + ).toBeUndefined(); + expect( + validateHttpChatConfig({ endpoint: "http://host", model: "m" }).apiKeyEnv, + ).toBeUndefined(); + }); + it("rejects zero / non-integer timeouts and tool-iteration guard", () => { + const errors = validateHttpChatConfig({ + endpoint: "http://host", + model: "m", + requestTimeoutMs: 0, + connectTimeoutMs: -1, + maxToolIterations: 1.5, + }); + expect(errors.requestTimeoutMs).toBeDefined(); + expect(errors.connectTimeoutMs).toBeDefined(); + expect(errors.maxToolIterations).toBeDefined(); + }); +}); + +describe("validateProfile for openAiCompatible profiles", () => { + function ollama(overrides: Partial = {}): AgentProfile { + return base({ + name: "Ollama", + command: "openai-compatible", + structuredAdapter: "openAiCompatible", + chatHttp: defaultHttpChatConfig(), + ...overrides, + }); + } + it("the reference local-model profile is valid", () => { + expect(validateProfile(ollama())).toEqual({}); + expect(isProfileValid(ollama())).toBe(true); + }); + it("surfaces chatHttp field errors through the profile validator", () => { + const errors = validateProfile( + ollama({ chatHttp: { endpoint: "nope", model: "" } }), + ); + expect(errors.endpoint).toBeDefined(); + expect(errors.model).toBeDefined(); + expect(isProfileValid(ollama({ chatHttp: { endpoint: "nope", model: "m" } }))).toBe( + false, + ); + }); + it("a missing chatHttp on an openAiCompatible profile is invalid", () => { + const errors = validateProfile(ollama({ chatHttp: undefined })); + expect(errors.endpoint).toBeDefined(); + expect(errors.model).toBeDefined(); + }); + it("does not touch validation of Claude/Codex (non-structured-http) profiles", () => { + // A plain profile with no structuredAdapter never triggers chatHttp checks. + expect(validateProfile(base())).toEqual({}); + }); +}); + describe("defaultInjection", () => { it("produces a sensible default per strategy", () => { expect(defaultInjection("conventionFile")).toEqual({ diff --git a/frontend/src/features/first-run/profile.ts b/frontend/src/features/first-run/profile.ts index 374e1c6..39988a4 100644 --- a/frontend/src/features/first-run/profile.ts +++ b/frontend/src/features/first-run/profile.ts @@ -7,12 +7,26 @@ import type { AgentProfile, ContextInjection, + HttpChatConfig, InjectionStrategy, } from "@/domain"; /** A field-keyed validation error map (empty ⇒ valid). */ export type ProfileErrors = Partial< - Record<"name" | "command" | "target" | "flag" | "var", string> + Record< + | "name" + | "command" + | "target" + | "flag" + | "var" + | "endpoint" + | "model" + | "apiKeyEnv" + | "requestTimeoutMs" + | "connectTimeoutMs" + | "maxToolIterations", + string + > >; /** Whether a path is a relative, traversal-free file name (mirror of backend). */ @@ -29,6 +43,58 @@ export function isValidEnvVar(v: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(v); } +/** + * Whether a string is a valid `http://`/`https://` endpoint (mirror of the + * backend `HttpChatConfig::new`: non-empty once trimmed, and the raw value + * starts with `http://` or `https://`). + */ +export function isValidHttpUrl(v: string): boolean { + if (v.trim().length === 0) return false; + return v.startsWith("http://") || v.startsWith("https://"); +} + +/** + * Whether a value is an acceptable positive-integer timeout/guard (mirror of the + * backend, which rejects `0` and only ever holds `u32`/`u16`). `undefined` means + * "left blank" and is valid (the backend applies its own default). + */ +function isValidPositiveInt(value: number | undefined, max: number): boolean { + if (value === undefined) return true; + return Number.isInteger(value) && value > 0 && value <= max; +} + +const MAX_U32 = 0xffff_ffff; +const MAX_U16 = 0xffff; + +/** + * Validates an {@link HttpChatConfig} draft the way the backend + * `HttpChatConfig::new` would, so the wizard can surface errors before any + * `invoke`. Returns an empty object when the draft is valid. + */ +export function validateHttpChatConfig(c: HttpChatConfig): ProfileErrors { + const errors: ProfileErrors = {}; + if (!isValidHttpUrl(c.endpoint)) { + errors.endpoint = "Endpoint must start with http:// or https://."; + } + if (c.model.trim().length === 0) { + errors.model = "Model is required."; + } + // The API key field holds the NAME of an environment variable, never a key. + if (c.apiKeyEnv !== undefined && c.apiKeyEnv.length > 0 && !isValidEnvVar(c.apiKeyEnv)) { + errors.apiKeyEnv = "Must be a valid env var name (not the key itself)."; + } + if (!isValidPositiveInt(c.requestTimeoutMs, MAX_U32)) { + errors.requestTimeoutMs = "Must be a positive integer (ms)."; + } + if (!isValidPositiveInt(c.connectTimeoutMs, MAX_U32)) { + errors.connectTimeoutMs = "Must be a positive integer (ms)."; + } + if (!isValidPositiveInt(c.maxToolIterations, MAX_U16)) { + errors.maxToolIterations = "Must be a positive integer."; + } + return errors; +} + /** * Validates a profile draft the way the backend would, so the wizard can surface * errors before any `invoke`. Returns an empty object when the draft is valid. @@ -48,6 +114,17 @@ export function validateProfile(p: AgentProfile): ProfileErrors { } else if (ci.strategy === "env") { if (!isValidEnvVar(ci.var)) errors.var = "Must be a valid env var identifier."; } + + // An OpenAI-compatible profile additionally carries an HTTP chat config; the + // backend refuses to persist it unless it is well-formed, so mirror that here. + if (p.structuredAdapter === "openAiCompatible") { + if (!p.chatHttp) { + errors.endpoint = "Endpoint must start with http:// or https://."; + errors.model = "Model is required."; + } else { + Object.assign(errors, validateHttpChatConfig(p.chatHttp)); + } + } return errors; } @@ -73,6 +150,21 @@ export function defaultInjection(strategy: InjectionStrategy): ContextInjection } } +/** + * A sensible default {@link HttpChatConfig} for a fresh OpenAI-compatible + * profile draft — pre-fills a local Ollama endpoint so the form starts valid. + * Mirrors the backend reference profile (`http://localhost:11434/v1`). + */ +export function defaultHttpChatConfig(): HttpChatConfig { + return { + endpoint: "http://localhost:11434/v1", + model: "qwen2.5-coder", + requestTimeoutMs: 120_000, + connectTimeoutMs: 5_000, + maxToolIterations: 16, + }; +} + /** A fresh, empty custom-profile draft (id minted client-side). */ export function emptyCustomProfile(): AgentProfile { return { diff --git a/frontend/src/features/terminals/TerminalView.test.tsx b/frontend/src/features/terminals/TerminalView.test.tsx index 049d566..b24ab37 100644 --- a/frontend/src/features/terminals/TerminalView.test.tsx +++ b/frontend/src/features/terminals/TerminalView.test.tsx @@ -10,7 +10,7 @@ * layout change) must **detach**, NEVER **close** — the backend PTY must survive * so a running AI isn't cut off. Re-mounting with a known session re-attaches. */ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import type { @@ -205,3 +205,88 @@ describe("TerminalView (with MockTerminalGateway)", () => { expect(true).toBe(true); }); }); + +// The launch-error surface (ticket #14 F3) can only be observed when xterm +// actually mounts — otherwise the effect bails before ever calling the opener. +// jsdom lacks `matchMedia`/`ResizeObserver`, which is exactly what makes +// `term.open` throw. This block installs minimal polyfills so xterm mounts and +// the opener genuinely runs (and rejects), letting us assert the real rendered +// error. Scoped + torn down so the rest of the file keeps its headless +// bail-graceful contract untouched. +describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () => { + const w = window as unknown as { + matchMedia?: (q: string) => MediaQueryList; + }; + const savedMatchMedia = w.matchMedia; + const savedResizeObserver = globalThis.ResizeObserver; + + beforeAll(() => { + w.matchMedia = (query: string) => + ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }) as unknown as MediaQueryList; + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + }); + + afterAll(() => { + w.matchMedia = savedMatchMedia; + globalThis.ResizeObserver = savedResizeObserver; + }); + + 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. + const open = vi.fn(async () => makeHandle({ sessionId: "up-1" })); + renderView(new MockTerminalGateway(), "/cwd", { open }); + await waitFor(() => expect(open).toHaveBeenCalled()); + }); + + it("renders an 'endpoint unavailable' launch failure as a visible alert without blocking the UI", async () => { + // An OpenAI-compatible agent whose endpoint is down rejects the launch with a + // Start error. The cell must show a visible, accessible error message — never + // throw, never crash — so IdeA stays usable and other cells keep working. + const open = vi.fn(async () => { + throw { + code: "AGENT_SESSION_START", + message: "endpoint indisponible: http://localhost:11434/v1", + }; + }); + + expect(() => + renderView(new MockTerminalGateway(), "/cwd", { open }), + ).not.toThrow(); + + // The rejection is actually exercised (the opener was invoked and threw). + await waitFor(() => expect(open).toHaveBeenCalled()); + + // A real, accessible error message is rendered to the user (role="alert"), + // carrying the endpoint diagnostic — not just painted into the xterm buffer. + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toMatch(/Échec du lancement de l'agent/); + expect(alert.textContent).toMatch(/endpoint indisponible/); + + // The cell stays mounted and interactive (not a blank/blocked screen). + expect(screen.getByTestId("terminal-view")).toBeTruthy(); + }); + + it("shows NO failure banner on a successful launch", async () => { + // Guard: the alert is strictly a failure surface — a healthy cell has none. + const open = vi.fn(async () => makeHandle({ sessionId: "ok-1" })); + renderView(new MockTerminalGateway(), "/cwd", { open }); + + await waitFor(() => expect(open).toHaveBeenCalled()); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.queryByTestId("terminal-error")).toBeNull(); + }); +}); diff --git a/frontend/src/features/terminals/TerminalView.tsx b/frontend/src/features/terminals/TerminalView.tsx index a9fb4d2..20dd4cc 100644 --- a/frontend/src/features/terminals/TerminalView.tsx +++ b/frontend/src/features/terminals/TerminalView.tsx @@ -32,7 +32,7 @@ * fresh one. If the session is gone (was explicitly closed), it opens fresh. */ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; @@ -108,6 +108,12 @@ export function TerminalView({ }: TerminalViewProps) { const { terminal } = useGateways(); const containerRef = useRef(null); + // A user-visible launch failure (e.g. an OpenAI-compatible endpoint that is + // unreachable). Rendered as a DOM `role="alert"` banner over the cell so the + // failure is always visible and accessible — not only painted into the xterm + // 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(null); // 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 @@ -138,6 +144,9 @@ export function TerminalView({ const reattacher = reattachRef.current ?? tgw?.reattach.bind(tgw); if (!container || !opener) return; + // Fresh (re)mount: clear any prior failure banner before we try to open. + setOpenError(null); + const term = new Terminal({ convertEol: false, cursorBlink: true, @@ -218,6 +227,10 @@ export function TerminalView({ ); return; } + // A genuine launch failure (unreachable endpoint, model missing, network + // drop…). Surface it as an accessible DOM banner AND as a red line in the + // buffer. The cell renders as failed; IdeA stays usable (other cells work). + setOpenError(`Échec du lancement de l'agent : ${describe(e)}`); term.write( `\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`, ); @@ -307,10 +320,41 @@ export function TerminalView({ return (
+ style={{ + position: "relative", + width: "100%", + height: "100%", + minHeight: "16rem", + }} + > + {/* xterm mounts into this inner node; the error banner is a sibling so + React never fights xterm over the same subtree. */} +
+ {openError && ( +
+ {openError} +
+ )} +
); }