Merge feature/ticket14-local-lan-profiles into develop (#14)
Intègre les profils IA locaux/LAN OpenAI-compatible comme profils IdeA canoniques (adapter HTTP additif, parité tool-calling/MCP). Backend (aab4bca) + frontend (d89380c), validés GO par QA de bout en bout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -1942,6 +1942,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"domain",
|
"domain",
|
||||||
"fastembed",
|
"fastembed",
|
||||||
|
"futures-util",
|
||||||
"git2",
|
"git2",
|
||||||
"landlock",
|
"landlock",
|
||||||
"notify",
|
"notify",
|
||||||
|
|||||||
@ -18,6 +18,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
futures-util = "0.3"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] }
|
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:
|
# 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
|
# only local operations (status/commit/branch/checkout/log) are in scope; remote
|
||||||
|
|||||||
@ -19,6 +19,7 @@ pub mod dto;
|
|||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod mcp_bridge;
|
pub mod mcp_bridge;
|
||||||
pub mod mcp_endpoint;
|
pub mod mcp_endpoint;
|
||||||
|
pub mod openai_tools;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod tickets;
|
pub mod tickets;
|
||||||
|
|||||||
149
crates/app-tauri/src/openai_tools.rs
Normal file
149
crates/app-tauri/src/openai_tools.rs
Normal file
@ -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<OrchestratorService>,
|
||||||
|
projects: Arc<dyn ProjectStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Option<Arc<dyn ToolInvoker>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<dyn ToolInvoker>) {
|
||||||
|
*self.inner.lock().expect("mutex sain") = Some(inner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ToolInvoker for LateBoundOpenAiToolInvoker {
|
||||||
|
fn tools(&self) -> Vec<ToolSpec> {
|
||||||
|
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<String, ToolInvocationError> {
|
||||||
|
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<OrchestratorService>, projects: Arc<dyn ProjectStore>) -> Self {
|
||||||
|
Self {
|
||||||
|
orchestrator,
|
||||||
|
projects,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ToolInvoker for AppOpenAiToolInvoker {
|
||||||
|
fn tools(&self) -> Vec<ToolSpec> {
|
||||||
|
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<String, ToolInvocationError> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,7 +49,7 @@ use domain::ports::{
|
|||||||
EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator,
|
EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator,
|
||||||
IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore,
|
IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore,
|
||||||
ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, TemplateStore,
|
ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, TemplateStore,
|
||||||
WakeError, WakeReason,
|
ToolInvoker, WakeError, WakeReason,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||||
@ -81,6 +81,7 @@ use infrastructure::{
|
|||||||
|
|
||||||
use crate::chat::ChatBridge;
|
use crate::chat::ChatBridge;
|
||||||
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
|
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
|
||||||
|
use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker};
|
||||||
use crate::pty::PtyBridge;
|
use crate::pty::PtyBridge;
|
||||||
use crate::tickets::AppTicketToolProvider;
|
use crate::tickets::AppTicketToolProvider;
|
||||||
|
|
||||||
@ -1084,9 +1085,12 @@ impl AppState {
|
|||||||
// `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et
|
// `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et
|
||||||
// ChangeAgentProfile (shutdown polymorphe au hot-swap).
|
// ChangeAgentProfile (shutdown polymorphe au hot-swap).
|
||||||
let structured_sessions = Arc::new(StructuredSessions::new());
|
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<dyn ToolInvoker>;
|
||||||
let session_factory = Arc::new(
|
let session_factory = Arc::new(
|
||||||
StructuredSessionFactory::new()
|
StructuredSessionFactory::new()
|
||||||
.with_sandbox_enforcer(infrastructure::default_enforcer()),
|
.with_sandbox_enforcer(infrastructure::default_enforcer())
|
||||||
|
.with_tool_invoker(openai_tool_invoker_port),
|
||||||
) as Arc<dyn AgentSessionFactory>;
|
) as Arc<dyn AgentSessionFactory>;
|
||||||
|
|
||||||
let open_terminal = Arc::new(OpenTerminal::new(
|
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.
|
// même use case que la commande Tauri, sans aller-retour frontend.
|
||||||
.with_spawn_background_command(Arc::clone(&spawn_background_command)),
|
.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<dyn ToolInvoker>);
|
||||||
|
|
||||||
let stop_live_agent = Arc::new(
|
let stop_live_agent = Arc::new(
|
||||||
StopLiveAgent::new(Arc::clone(&live_sessions), Arc::clone(&close_terminal))
|
StopLiveAgent::new(Arc::clone(&live_sessions), Arc::clone(&close_terminal))
|
||||||
|
|||||||
@ -20,7 +20,7 @@
|
|||||||
use domain::ids::ProfileId;
|
use domain::ids::ProfileId;
|
||||||
use domain::permission::ProjectorKey;
|
use domain::permission::ProjectorKey;
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
AgentProfile, ContextInjection, HttpChatConfig, McpCapability, McpConfigStrategy, McpTransport,
|
||||||
StructuredAdapter,
|
StructuredAdapter,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -96,6 +96,30 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
.expect(".codex/config.toml + CODEX_HOME is a valid MCP config target"),
|
.expect(".codex/config.toml + CODEX_HOME is a valid MCP config target"),
|
||||||
McpTransport::Stdio,
|
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(
|
AgentProfile::new(
|
||||||
reference_id("gemini"),
|
reference_id("gemini"),
|
||||||
"Gemini CLI",
|
"Gemini CLI",
|
||||||
@ -129,8 +153,9 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
///
|
///
|
||||||
/// A profile is selectable iff it can be driven in **structured** mode
|
/// A profile is selectable iff it can be driven in **structured** mode
|
||||||
/// ([`AgentProfile::is_selectable`] = it carries a `structured_adapter`). Today
|
/// ([`AgentProfile::is_selectable`] = it carries a `structured_adapter`). Today
|
||||||
/// that is Claude + Codex; Gemini/Aider stay in [`reference_profiles`] (the data
|
/// that is Claude + Codex + OpenAI-compatible; Gemini/Aider stay in
|
||||||
/// catalogue is untouched) but are **not** proposed for selection. There is no
|
/// [`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
|
/// custom-profile entry here either: the selection path offers only profiles we
|
||||||
/// know how to pilot.
|
/// 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]
|
#[test]
|
||||||
fn gemini_and_aider_have_no_mcp_capability() {
|
fn gemini_and_aider_have_no_mcp_capability() {
|
||||||
for slug in ["gemini", "aider"] {
|
for slug in ["gemini", "aider"] {
|
||||||
|
|||||||
@ -217,10 +217,11 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() {
|
|||||||
let out = uc.execute().await.unwrap();
|
let out = uc.execute().await.unwrap();
|
||||||
assert!(out.is_first_run);
|
assert!(out.is_first_run);
|
||||||
// §17.3/D7: the wizard is seeded only with the *selectable* (structured-
|
// §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!(
|
assert_eq!(
|
||||||
out.reference_profiles.len(),
|
out.reference_profiles.len(),
|
||||||
2,
|
3,
|
||||||
"selectable catalogue seeded"
|
"selectable catalogue seeded"
|
||||||
);
|
);
|
||||||
let commands: Vec<&str> = out
|
let commands: Vec<&str> = out
|
||||||
@ -230,8 +231,8 @@ async fn first_run_true_when_not_configured_with_reference_catalogue() {
|
|||||||
.collect();
|
.collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
commands,
|
commands,
|
||||||
vec!["claude", "codex"],
|
vec!["claude", "codex", "openai-compatible"],
|
||||||
"only Claude/Codex offered"
|
"only structured profiles offered"
|
||||||
);
|
);
|
||||||
// Every seeded profile is selectable (the gate the menu relies on).
|
// Every seeded profile is selectable (the gate the menu relies on).
|
||||||
assert!(
|
assert!(
|
||||||
@ -299,32 +300,57 @@ async fn delete_unknown_is_not_found_error() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reference_profiles_use_case_returns_only_selectable() {
|
async fn reference_profiles_use_case_returns_only_selectable() {
|
||||||
// §17.3/D7: the selection use case exposes only structured-drivable profiles
|
// §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.
|
// `catalogue_*` tests below) but are not offered to selection/creation.
|
||||||
let out = ReferenceProfiles::new().execute().await.unwrap();
|
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();
|
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));
|
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.
|
/// (data intact). Only the selection-facing use case is filtered.
|
||||||
#[test]
|
#[test]
|
||||||
fn raw_catalogue_still_has_all_four_profiles() {
|
fn raw_catalogue_still_has_all_profiles() {
|
||||||
let commands: Vec<String> = reference_profiles()
|
let commands: Vec<String> = reference_profiles()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| p.command.clone())
|
.map(|p| p.command.clone())
|
||||||
.collect();
|
.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
|
/// 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
|
/// would flip (and fail) the moment Gemini/Aider gained an adapter or Claude/Codex
|
||||||
/// lost theirs — i.e. it actually constrains behaviour.
|
/// lost theirs — i.e. it actually constrains behaviour.
|
||||||
#[test]
|
#[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 profiles = reference_profiles();
|
||||||
let by_command: HashMap<&str, &AgentProfile> =
|
let by_command: HashMap<&str, &AgentProfile> =
|
||||||
profiles.iter().map(|p| (p.command.as_str(), p)).collect();
|
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(),
|
by_command["codex"].is_selectable(),
|
||||||
"Codex carries a structured adapter ⇒ selectable"
|
"Codex carries a structured adapter ⇒ selectable"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
by_command["openai-compatible"].is_selectable(),
|
||||||
|
"OpenAI-compatible carries a structured adapter ⇒ selectable"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!by_command["gemini"].is_selectable(),
|
!by_command["gemini"].is_selectable(),
|
||||||
"Gemini has no adapter ⇒ not selectable"
|
"Gemini has no adapter ⇒ not selectable"
|
||||||
|
|||||||
@ -9,9 +9,9 @@ description = "IdeA — pure domain layer: entities, value objects, ports (trait
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json = { workspace = true }
|
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use serde_json::Value;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::agent::AgentManifest;
|
use crate::agent::AgentManifest;
|
||||||
@ -368,6 +369,48 @@ pub enum ReplyEvent {
|
|||||||
/// clôt le flux ; deltas, activités et heartbeats sont tous non terminaux.
|
/// clôt le flux ; deltas, activités et heartbeats sont tous non terminaux.
|
||||||
pub type ReplyStream = Box<dyn Iterator<Item = ReplyEvent> + Send>;
|
pub type ReplyStream = Box<dyn Iterator<Item = ReplyEvent> + 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<ToolSpec>;
|
||||||
|
|
||||||
|
/// 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<String, ToolInvocationError>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Intention **model-agnostique** exécutée à l'échéance d'un réveil de
|
/// Intention **model-agnostique** exécutée à l'échéance d'un réveil de
|
||||||
/// [`Scheduler`] (ARCHITECTURE §21.4).
|
/// [`Scheduler`] (ARCHITECTURE §21.4).
|
||||||
///
|
///
|
||||||
|
|||||||
@ -246,6 +246,8 @@ pub enum StructuredAdapter {
|
|||||||
Claude,
|
Claude,
|
||||||
/// Piloté par `CodexExecSession` (`codex exec` structuré).
|
/// Piloté par `CodexExecSession` (`codex exec` structuré).
|
||||||
Codex,
|
Codex,
|
||||||
|
/// Piloté par l'adapter HTTP OpenAI-compatible (Ollama, llama.cpp, runtime LAN).
|
||||||
|
OpenAiCompatible,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StructuredAdapter {
|
impl StructuredAdapter {
|
||||||
@ -262,6 +264,98 @@ impl StructuredAdapter {
|
|||||||
match self {
|
match self {
|
||||||
Self::Claude => "claude",
|
Self::Claude => "claude",
|
||||||
Self::Codex => "codex",
|
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<String>,
|
||||||
|
/// Timeout de requête par tour, en millisecondes.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub request_timeout_ms: Option<u32>,
|
||||||
|
/// Timeout de connexion, en millisecondes.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub connect_timeout_ms: Option<u32>,
|
||||||
|
/// Plafond de rebouclage tool-calling pour un tour.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_tool_iterations: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
model: impl Into<String>,
|
||||||
|
api_key_env: Option<String>,
|
||||||
|
request_timeout_ms: Option<u32>,
|
||||||
|
connect_timeout_ms: Option<u32>,
|
||||||
|
max_tool_iterations: Option<u16>,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
|
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.
|
/// sérialisation : un profil sans adapter sérialise exactement comme avant.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub structured_adapter: Option<StructuredAdapter>,
|
pub structured_adapter: Option<StructuredAdapter>,
|
||||||
|
/// 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<HttpChatConfig>,
|
||||||
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
|
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
|
||||||
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
|
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
|
||||||
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
|
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
|
||||||
@ -772,6 +870,7 @@ impl AgentProfile {
|
|||||||
cwd_template,
|
cwd_template,
|
||||||
session,
|
session,
|
||||||
structured_adapter: None,
|
structured_adapter: None,
|
||||||
|
chat_http: None,
|
||||||
mcp: None,
|
mcp: None,
|
||||||
liveness: None,
|
liveness: None,
|
||||||
rate_limit_pattern: None,
|
rate_limit_pattern: None,
|
||||||
@ -790,6 +889,13 @@ impl AgentProfile {
|
|||||||
self
|
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
|
/// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
|
||||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||||
/// profils sans MCP ne l'appellent simplement pas.
|
/// profils sans MCP ne l'appellent simplement pas.
|
||||||
@ -886,6 +992,7 @@ impl AgentProfile {
|
|||||||
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => {
|
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
(Some(StructuredAdapter::OpenAiCompatible), _) => false,
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -934,6 +1041,70 @@ mod mcp_tests {
|
|||||||
assert!(back.mcp.is_none());
|
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]
|
#[test]
|
||||||
fn legacy_json_without_mcp_field_deserialises_to_none() {
|
fn legacy_json_without_mcp_field_deserialises_to_none() {
|
||||||
// JSON produced before the `mcp` field existed: no `mcp` key at all.
|
// JSON produced before the `mcp` field existed: no `mcp` key at all.
|
||||||
|
|||||||
@ -16,6 +16,7 @@ application = { workspace = true }
|
|||||||
tokio = { workspace = true, features = ["process", "time"] }
|
tokio = { workspace = true, features = ["process", "time"] }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
futures-util = { workspace = true }
|
||||||
# Ergonomic error enums for the MCP adapter (tool-mapping / transport errors).
|
# Ergonomic error enums for the MCP adapter (tool-mapping / transport errors).
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
serde = { 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
|
# §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
|
# AppImage stays portable; pulled in *only* under the `vector-http` feature so the
|
||||||
# zero-dependency default (`none` strategy) compiles nothing extra.
|
# 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
|
# 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
|
# (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
|
# AppImage stays self-contained and OpenSSL-free. Pulled in *only* under the
|
||||||
@ -51,6 +52,6 @@ landlock = "0.4.5"
|
|||||||
[features]
|
[features]
|
||||||
# Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…).
|
# Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…).
|
||||||
# OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency.
|
# 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.
|
# Real in-process ONNX embedder (`localOnnx`). OFF by default, same posture.
|
||||||
vector-onnx = ["dep:fastembed"]
|
vector-onnx = ["dep:fastembed"]
|
||||||
|
|||||||
@ -10,9 +10,11 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, SessionPlan,
|
AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, SessionPlan,
|
||||||
|
ToolInvocationError, ToolInvoker, ToolSpec,
|
||||||
};
|
};
|
||||||
use domain::profile::{AgentProfile, StructuredAdapter};
|
use domain::profile::{AgentProfile, StructuredAdapter};
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
@ -21,6 +23,46 @@ use domain::SessionId;
|
|||||||
|
|
||||||
use super::claude::ClaudeSdkSession;
|
use super::claude::ClaudeSdkSession;
|
||||||
use super::codex::CodexExecSession;
|
use super::codex::CodexExecSession;
|
||||||
|
use super::openai_compat::OpenAiCompatibleSession;
|
||||||
|
|
||||||
|
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
|
||||||
|
const REQUESTER_ARG: &str = "__ideaRequester";
|
||||||
|
|
||||||
|
struct ProjectScopedToolInvoker {
|
||||||
|
inner: Arc<dyn ToolInvoker>,
|
||||||
|
project_root: String,
|
||||||
|
requester: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ToolInvoker for ProjectScopedToolInvoker {
|
||||||
|
fn tools(&self) -> Vec<ToolSpec> {
|
||||||
|
self.inner.tools()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
|
||||||
|
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.
|
/// 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
|
/// Enforcer OS optionnel passé aux adapters structurés. `None` ⇒ aucun
|
||||||
/// sandboxing (chemin natif inchangé, zéro régression).
|
/// sandboxing (chemin natif inchangé, zéro régression).
|
||||||
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||||
|
/// Invoker outil optionnel pour les adapters sans client MCP natif.
|
||||||
|
tool_invoker: Option<Arc<dyn ToolInvoker>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StructuredSessionFactory {
|
impl StructuredSessionFactory {
|
||||||
@ -43,6 +87,7 @@ impl StructuredSessionFactory {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
sandbox_enforcer: None,
|
sandbox_enforcer: None,
|
||||||
|
tool_invoker: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,6 +100,14 @@ impl StructuredSessionFactory {
|
|||||||
self.sandbox_enforcer = Some(enforcer);
|
self.sandbox_enforcer = Some(enforcer);
|
||||||
self
|
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<dyn ToolInvoker>) -> Self {
|
||||||
|
self.tool_invoker = Some(invoker);
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dérive l'[`SessionPlan`] le `seed` de reprise : seul [`SessionPlan::Resume`]
|
/// 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 command = profile.command.clone();
|
||||||
let cwd = cwd.as_str().to_owned();
|
let cwd = cwd.as_str().to_owned();
|
||||||
let seed = seed_conversation_id(session);
|
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
|
// Appariement (lot LP4-4) : plan **par lancement** (param) + enforcer **par
|
||||||
// instance** (champ). Tous deux sont relayés à l'adapter, qui remplira
|
// instance** (champ). Tous deux sont relayés à l'adapter, qui remplira
|
||||||
@ -121,6 +179,27 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
|||||||
plan,
|
plan,
|
||||||
enforcer,
|
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<dyn ToolInvoker>
|
||||||
|
}),
|
||||||
|
)?)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ pub mod claude;
|
|||||||
pub mod codex;
|
pub mod codex;
|
||||||
pub mod conformance;
|
pub mod conformance;
|
||||||
pub mod factory;
|
pub mod factory;
|
||||||
|
pub mod openai_compat;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
|
|
||||||
/// Tests bout-en-bout de l'enforcement Landlock sur le chemin structuré (lot LP4-4),
|
/// 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 codex::CodexExecSession;
|
||||||
pub use conformance::FakeCli;
|
pub use conformance::FakeCli;
|
||||||
pub use factory::StructuredSessionFactory;
|
pub use factory::StructuredSessionFactory;
|
||||||
|
pub use openai_compat::OpenAiCompatibleSession;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
use domain::ids::ProfileId;
|
use domain::ids::ProfileId;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentSession, AgentSessionError, AgentSessionFactory, ContextInjectionPlan,
|
AgentSession, AgentSessionError, AgentSessionFactory, ContextInjectionPlan,
|
||||||
PreparedContext, ReplyEvent, SessionPlan,
|
PreparedContext, ReplyEvent, SessionPlan,
|
||||||
};
|
};
|
||||||
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
use domain::profile::{AgentProfile, ContextInjection, HttpChatConfig, StructuredAdapter};
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
use domain::{MarkdownDoc, SessionId};
|
use domain::{MarkdownDoc, SessionId};
|
||||||
|
|
||||||
@ -70,8 +75,17 @@ mod tests {
|
|||||||
ProjectPath::new("/").expect("cwd valide")
|
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 {
|
fn structured_profile(adapter: StructuredAdapter, command: &str) -> AgentProfile {
|
||||||
AgentProfile::new(
|
let profile = AgentProfile::new(
|
||||||
ProfileId::new_random(),
|
ProfileId::new_random(),
|
||||||
"Profil structuré",
|
"Profil structuré",
|
||||||
command,
|
command,
|
||||||
@ -82,7 +96,57 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.expect("profil valide")
|
.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) -------------------
|
// -- Machinerie de process (paramétrable, fake CLI) -------------------
|
||||||
@ -404,13 +468,14 @@ mod tests {
|
|||||||
let mut expected: HashMap<&str, bool> = HashMap::new();
|
let mut expected: HashMap<&str, bool> = HashMap::new();
|
||||||
expected.insert("claude", true);
|
expected.insert("claude", true);
|
||||||
expected.insert("codex", true);
|
expected.insert("codex", true);
|
||||||
|
expected.insert("openai-compatible", true);
|
||||||
expected.insert("gemini", false);
|
expected.insert("gemini", false);
|
||||||
expected.insert("aider", false);
|
expected.insert("aider", false);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
profiles.len(),
|
profiles.len(),
|
||||||
4,
|
5,
|
||||||
"catalogue has the four reference profiles"
|
"catalogue has the five reference profiles"
|
||||||
);
|
);
|
||||||
for profile in &profiles {
|
for profile in &profiles {
|
||||||
let selectable = profile.is_selectable();
|
let selectable = profile.is_selectable();
|
||||||
@ -454,6 +519,43 @@ mod tests {
|
|||||||
assert_eq!(content_cx, "réponse Codex");
|
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]
|
#[tokio::test]
|
||||||
async fn factory_passes_project_root_to_codex_add_dir() {
|
async fn factory_passes_project_root_to_codex_add_dir() {
|
||||||
let factory = StructuredSessionFactory::new();
|
let factory = StructuredSessionFactory::new();
|
||||||
|
|||||||
1144
crates/infrastructure/src/session/openai_compat.rs
Normal file
1144
crates/infrastructure/src/session/openai_compat.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -1159,6 +1159,23 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
|
|||||||
detect: "codex --version",
|
detect: "codex --version",
|
||||||
cwdTemplate: "{projectRoot}",
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -22,14 +22,16 @@ function customProfile(id: string, command: string): AgentProfile {
|
|||||||
|
|
||||||
describe("MockProfileGateway", () => {
|
describe("MockProfileGateway", () => {
|
||||||
it("firstRunState is first-run with only the selectable reference profiles", async () => {
|
it("firstRunState is first-run with only the selectable reference profiles", async () => {
|
||||||
// §17.3/D7: only structured-drivable profiles (Claude/Codex) are offered;
|
// §17.3/D7: only structured-drivable profiles are offered (Claude/Codex CLIs
|
||||||
// Gemini/Aider are filtered out of the selection path server-side.
|
// + the OpenAI-compatible local/LAN adapter, ticket #14); Gemini/Aider are
|
||||||
|
// filtered out of the selection path server-side.
|
||||||
const gw = new MockProfileGateway();
|
const gw = new MockProfileGateway();
|
||||||
const state = await gw.firstRunState();
|
const state = await gw.firstRunState();
|
||||||
expect(state.isFirstRun).toBe(true);
|
expect(state.isFirstRun).toBe(true);
|
||||||
expect(state.referenceProfiles.map((p) => p.command)).toEqual([
|
expect(state.referenceProfiles.map((p) => p.command)).toEqual([
|
||||||
"claude",
|
"claude",
|
||||||
"codex",
|
"codex",
|
||||||
|
"openai-compatible",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -51,6 +53,7 @@ describe("MockProfileGateway", () => {
|
|||||||
expect(byCommand).toEqual({
|
expect(byCommand).toEqual({
|
||||||
claude: true,
|
claude: true,
|
||||||
codex: false,
|
codex: false,
|
||||||
|
"openai-compatible": false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -626,6 +626,42 @@ export type ContextInjection =
|
|||||||
/** The four injection strategy discriminants. */
|
/** The four injection strategy discriminants. */
|
||||||
export type InjectionStrategy = ContextInjection["strategy"];
|
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
|
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
|
||||||
* UUID string; `detect` is the optional detection command line.
|
* 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
|
/** Optional CLI flags for agent-session continuity: `assignFlag` to bind a
|
||||||
* conversation id at launch, `resumeFlag` to resume an existing conversation. */
|
* conversation id at launch, `resumeFlag` to resume an existing conversation. */
|
||||||
session?: { assignFlag?: string; resumeFlag: string };
|
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). */
|
/** Availability of a candidate profile after detection (mirror of the DTO). */
|
||||||
|
|||||||
@ -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)", () => {
|
describe("FirstRunWizard reopening after the first run (forceOpen)", () => {
|
||||||
/** A gateway whose first run is already done (profiles configured). */
|
/** A gateway whose first run is already done (profiles configured). */
|
||||||
async function configuredGateway() {
|
async function configuredGateway() {
|
||||||
|
|||||||
@ -15,10 +15,15 @@
|
|||||||
* `./profile`.
|
* `./profile`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AgentProfile } from "@/domain";
|
import type { AgentProfile, HttpChatConfig } from "@/domain";
|
||||||
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
||||||
import { useFirstRun, type WizardEntry } from "./useFirstRun";
|
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. */
|
/** A small caption above a control. */
|
||||||
function Caption({ children }: { children: React.ReactNode }) {
|
function Caption({ children }: { children: React.ReactNode }) {
|
||||||
@ -174,6 +179,133 @@ function ProfileRow({
|
|||||||
onChange={(e) => onChange({ ...profile, args: parseArgs(e.target.value) })}
|
onChange={(e) => onChange({ ...profile, args: parseArgs(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{profile.structuredAdapter === "openAiCompatible" && (
|
||||||
|
<HttpChatFields profile={profile} errors={errors} onChange={onChange} />
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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<HttpChatConfig>) =>
|
||||||
|
onChange({ ...profile, chatHttp: { ...http, ...next } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
|
||||||
|
<legend className="px-1 text-xs font-medium text-muted">
|
||||||
|
Local / LAN model (OpenAI-compatible)
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Endpoint</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} endpoint`}
|
||||||
|
value={http.endpoint}
|
||||||
|
placeholder="http://localhost:11434/v1"
|
||||||
|
invalid={Boolean(errors.endpoint)}
|
||||||
|
onChange={(e) => patch({ endpoint: e.target.value })}
|
||||||
|
/>
|
||||||
|
{errors.endpoint && (
|
||||||
|
<small className="text-xs text-danger">{errors.endpoint}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Model</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} model`}
|
||||||
|
value={http.model}
|
||||||
|
placeholder="qwen2.5-coder"
|
||||||
|
invalid={Boolean(errors.model)}
|
||||||
|
onChange={(e) => patch({ model: e.target.value })}
|
||||||
|
/>
|
||||||
|
{errors.model && <small className="text-xs text-danger">{errors.model}</small>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>API key — environment variable name (never the key itself)</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} api key env`}
|
||||||
|
value={http.apiKeyEnv ?? ""}
|
||||||
|
placeholder="e.g. OPENAI_API_KEY (variable name, not the secret)"
|
||||||
|
invalid={Boolean(errors.apiKeyEnv)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value.trim();
|
||||||
|
patch({ apiKeyEnv: v.length === 0 ? undefined : v });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{errors.apiKeyEnv && (
|
||||||
|
<small className="text-xs text-danger">{errors.apiKeyEnv}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||||||
|
<Caption>Request timeout (ms)</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} request timeout`}
|
||||||
|
value={http.requestTimeoutMs?.toString() ?? ""}
|
||||||
|
inputMode="numeric"
|
||||||
|
invalid={Boolean(errors.requestTimeoutMs)}
|
||||||
|
onChange={(e) => patch({ requestTimeoutMs: parseOptInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
{errors.requestTimeoutMs && (
|
||||||
|
<small className="text-xs text-danger">{errors.requestTimeoutMs}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||||||
|
<Caption>Connect timeout (ms)</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} connect timeout`}
|
||||||
|
value={http.connectTimeoutMs?.toString() ?? ""}
|
||||||
|
inputMode="numeric"
|
||||||
|
invalid={Boolean(errors.connectTimeoutMs)}
|
||||||
|
onChange={(e) => patch({ connectTimeoutMs: parseOptInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
{errors.connectTimeoutMs && (
|
||||||
|
<small className="text-xs text-danger">{errors.connectTimeoutMs}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||||||
|
<Caption>Max tool iterations</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} max tool iterations`}
|
||||||
|
value={http.maxToolIterations?.toString() ?? ""}
|
||||||
|
inputMode="numeric"
|
||||||
|
invalid={Boolean(errors.maxToolIterations)}
|
||||||
|
onChange={(e) => patch({ maxToolIterations: parseOptInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
{errors.maxToolIterations && (
|
||||||
|
<small className="text-xs text-danger">{errors.maxToolIterations}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -7,12 +7,15 @@ import { describe, it, expect } from "vitest";
|
|||||||
|
|
||||||
import type { AgentProfile } from "@/domain";
|
import type { AgentProfile } from "@/domain";
|
||||||
import {
|
import {
|
||||||
|
defaultHttpChatConfig,
|
||||||
defaultInjection,
|
defaultInjection,
|
||||||
emptyCustomProfile,
|
emptyCustomProfile,
|
||||||
isProfileValid,
|
isProfileValid,
|
||||||
isRelativeSafe,
|
isRelativeSafe,
|
||||||
isValidEnvVar,
|
isValidEnvVar,
|
||||||
|
isValidHttpUrl,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
|
validateHttpChatConfig,
|
||||||
validateProfile,
|
validateProfile,
|
||||||
} from "./profile";
|
} 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> = {}): 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", () => {
|
describe("defaultInjection", () => {
|
||||||
it("produces a sensible default per strategy", () => {
|
it("produces a sensible default per strategy", () => {
|
||||||
expect(defaultInjection("conventionFile")).toEqual({
|
expect(defaultInjection("conventionFile")).toEqual({
|
||||||
|
|||||||
@ -7,12 +7,26 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
ContextInjection,
|
ContextInjection,
|
||||||
|
HttpChatConfig,
|
||||||
InjectionStrategy,
|
InjectionStrategy,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
|
|
||||||
/** A field-keyed validation error map (empty ⇒ valid). */
|
/** A field-keyed validation error map (empty ⇒ valid). */
|
||||||
export type ProfileErrors = Partial<
|
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). */
|
/** 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);
|
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
|
* 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.
|
* 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") {
|
} else if (ci.strategy === "env") {
|
||||||
if (!isValidEnvVar(ci.var)) errors.var = "Must be a valid env var identifier.";
|
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;
|
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). */
|
/** A fresh, empty custom-profile draft (id minted client-side). */
|
||||||
export function emptyCustomProfile(): AgentProfile {
|
export function emptyCustomProfile(): AgentProfile {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
* layout change) must **detach**, NEVER **close** — the backend PTY must survive
|
* 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.
|
* 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 { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -205,3 +205,88 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
|||||||
expect(true).toBe(true);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -32,7 +32,7 @@
|
|||||||
* fresh one. If the session is gone (was explicitly closed), it opens fresh.
|
* 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 { Terminal } from "@xterm/xterm";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
@ -108,6 +108,12 @@ export function TerminalView({
|
|||||||
}: TerminalViewProps) {
|
}: TerminalViewProps) {
|
||||||
const { terminal } = useGateways();
|
const { terminal } = useGateways();
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(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<string | null>(null);
|
||||||
|
|
||||||
// The opener (`open` or the terminal gateway) is read through a ref so the
|
// 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
|
// 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);
|
const reattacher = reattachRef.current ?? tgw?.reattach.bind(tgw);
|
||||||
if (!container || !opener) return;
|
if (!container || !opener) return;
|
||||||
|
|
||||||
|
// Fresh (re)mount: clear any prior failure banner before we try to open.
|
||||||
|
setOpenError(null);
|
||||||
|
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
convertEol: false,
|
convertEol: false,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
@ -218,6 +227,10 @@ export function TerminalView({
|
|||||||
);
|
);
|
||||||
return;
|
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(
|
term.write(
|
||||||
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
|
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
|
||||||
);
|
);
|
||||||
@ -307,10 +320,41 @@ export function TerminalView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
|
||||||
data-testid="terminal-view"
|
data-testid="terminal-view"
|
||||||
style={{ width: "100%", height: "100%", minHeight: "16rem" }}
|
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. */}
|
||||||
|
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||||
|
{openError && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-testid="terminal-error"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
background: "rgba(120, 20, 20, 0.92)",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily:
|
||||||
|
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
zIndex: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{openError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user