From bb6f1e9a383184ad00edcc54757998df281083f3 Mon Sep 17 00:00:00 2001 From: Sami Shukri Date: Tue, 23 Jun 2026 11:26:54 -0600 Subject: [PATCH] feat: drive Gemini over ACP (thin wrapper on animus-provider-acp v0.1.1) Replace the bespoke stdout-scraping backend with the shared ACP client: animus-provider-gemini is now a thin wrapper that pins the harness to `gemini --acp` and advertises provider_tool="gemini". The kernel routes Gemini models here exactly as before; ACP (structured streaming + native permission callback, gated via approve-hook) is an internal transport detail. - depend on animus-provider-acp v0.1.1; align animus-protocol deps to v0.1.17 - main.rs: AcpConfig::for_harness("gemini", "gemini", ["--acp"], ...) - remove old backend.rs/config.rs/lib.rs + stdout contract test - bump 0.2.6 -> 0.3.0 --- Cargo.toml | 32 +--- README.md | 6 +- src/backend.rs | 359 ------------------------------------ src/config.rs | 37 ---- src/lib.rs | 9 - src/main.rs | 83 +++++---- tests/contract.rs | 456 ---------------------------------------------- 7 files changed, 61 insertions(+), 921 deletions(-) delete mode 100644 src/backend.rs delete mode 100644 src/config.rs delete mode 100644 src/lib.rs delete mode 100644 tests/contract.rs diff --git a/Cargo.toml b/Cargo.toml index 76e60cd..9963cce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,41 +1,29 @@ [package] name = "animus-provider-gemini" -version = "0.2.6" +version = "0.3.0" edition = "2021" license = "MIT" -description = "Google Gemini CLI provider plugin for Animus" +description = "Google Gemini provider plugin for Animus (drives the Gemini CLI over ACP)" repository = "https://github.com/launchapp-dev/animus-provider-gemini" homepage = "https://github.com/launchapp-dev/animus-cli" readme = "README.md" default-run = "animus-provider-gemini" -[lib] -name = "animus_provider_gemini" -path = "src/lib.rs" - [[bin]] name = "animus-provider-gemini" path = "src/main.rs" [dependencies] -animus-plugin-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.13.5" } -animus-provider-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.13.5" } -animus-plugin-runtime = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.13.5" } -animus-session-backend = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.13.5" } - -serde = { version = "1", features = ["derive"] } -serde_json = "1" +# Reuse the shared ACP client. This plugin is a thin wrapper that pins the +# harness to `gemini --acp` and advertises provider_tool = "gemini", so the +# kernel routes Gemini models here while ACP stays an internal transport detail. +animus-provider-acp = { git = "https://github.com/launchapp-dev/animus-provider-acp", tag = "v0.1.1" } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "process", "sync", "time"] } -async-trait = "0.1" -futures = "0.3" +animus-plugin-protocol = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.17" } +animus-plugin-runtime = { git = "https://github.com/launchapp-dev/animus-protocol", tag = "v0.1.17" } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } anyhow = "1" -thiserror = "1" - tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -[dev-dependencies] -tempfile = "3" -tokio = { version = "1", features = ["test-util", "macros", "rt-multi-thread"] } diff --git a/README.md b/README.md index 5218b72..5b705be 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ A [Google Gemini CLI](https://github.com/google-gemini/gemini-cli) provider plugin for [Animus](https://github.com/launchapp-dev/animus-cli). -> **Status:** Under construction — landing in Animus v0.4.x. This crate currently lives in the Animus core workspace at `crates/animus-provider-gemini/`; v0.4.x extracts it to this standalone repository. - ## What this is -Animus v0.4.0 makes providers (LLM CLI wrappers) pluggable. This repository will ship `animus-provider-gemini`, a stdio plugin that wraps Google's Gemini CLI as an Animus provider. Any workflow phase that targets `tool: gemini` dispatches through this plugin. +A stdio provider plugin that exposes Google's Gemini CLI as an Animus provider. Any workflow phase that targets `tool: gemini` dispatches through this plugin. + +As of v0.3.0 it drives the Gemini CLI over the **Agent Client Protocol (ACP)** — `gemini --acp` — rather than scraping stdout. It is a thin wrapper over the shared ACP client ([`animus-provider-acp`](https://github.com/launchapp-dev/animus-provider-acp)), pinned to the Gemini harness and advertising `provider_tool = "gemini"`. This gives structured streaming + tool events and a **native permission callback**, with every tool call gated through `animus agent approve-hook`. ACP is an internal transport detail; the kernel still routes Gemini models to `tool: gemini` exactly as before. ## Install (planned) diff --git a/src/backend.rs b/src/backend.rs deleted file mode 100644 index 2913273..0000000 --- a/src/backend.rs +++ /dev/null @@ -1,359 +0,0 @@ -use std::sync::Arc; -use std::time::Instant; - -use animus_plugin_protocol::{HealthCheckResult, HealthStatus}; -use animus_provider_protocol::{ - AgentNotification, AgentResumeRequest, AgentRunRequest, AgentRunResponse, BackendError, - InteractionRequestPayload, NotificationSink, ProviderBackend, ProviderCapabilities, - ProviderManifest, -}; -use animus_session_backend::{ - lookup_binary_in_path, GeminiSessionBackend, SessionBackend, SessionEvent, SessionRequest, -}; -use async_trait::async_trait; - -use crate::config::GeminiConfig; - -/// Provider plugin backend that wraps a `SessionBackend` (the native Gemini -/// CLI driver by default) behind the `ProviderBackend` trait. -pub struct GeminiProviderBackend { - session: Arc, - config: GeminiConfig, -} - -impl GeminiProviderBackend { - /// Build a backend with the bundled `GeminiSessionBackend` driver. - pub fn new(config: GeminiConfig) -> Self { - Self { - session: Arc::new(GeminiSessionBackend::new()), - config, - } - } - - /// Test/embedder constructor that lets callers inject any - /// `SessionBackend` implementation (e.g. a fake for contract tests). - pub fn with_session(session: S, config: GeminiConfig) -> Self - where - S: SessionBackend + 'static, - { - Self { - session: Arc::new(session), - config, - } - } - - fn build_session_request(&self, request: &AgentRunRequest) -> SessionRequest { - let model = request - .model - .clone() - .unwrap_or_else(|| self.config.default_model.clone()); - - let env_vars = request - .env - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::>(); - - let mut extras = serde_json::Map::new(); - if let Some(contract) = &request.runtime_contract { - extras.insert("runtime_contract".to_string(), contract.clone()); - } - if let Some(system) = &request.system_prompt { - extras.insert("system_prompt".to_string(), system.clone().into()); - } - if let Some(tools) = &request.tools { - extras.insert("tools".to_string(), tools.clone()); - } - if let Some(schema) = &request.response_schema { - extras.insert("response_schema".to_string(), schema.clone()); - } - for (key, value) in &request.extras { - extras.entry(key.clone()).or_insert(value.clone()); - } - - SessionRequest { - tool: "gemini".to_string(), - model, - prompt: request.prompt.clone(), - cwd: request.cwd.clone(), - project_root: request.project_root.clone(), - mcp_endpoint: None, - mcp_servers: request.mcp_servers.clone(), - permission_mode: request.permission_mode.clone(), - timeout_secs: request.timeout_secs, - env_vars, - extras: serde_json::Value::Object(extras), - } - } - - async fn drain_events( - &self, - mut run: animus_session_backend::SessionRun, - started: Instant, - model_label: String, - sink: NotificationSink, - ) -> Result { - let mut output = String::new(); - let mut thinking: Vec = Vec::new(); - let mut errors: Vec = Vec::new(); - let mut tool_calls: Vec = Vec::new(); - let mut tool_results: Vec = Vec::new(); - let mut metadata: Vec = Vec::new(); - let mut session_id = run.session_id.clone(); - let mut backend_label = run.selected_backend.clone(); - let mut exit_code: i32 = 0; - - while let Some(event) = run.events.recv().await { - let current_session_id = session_id.clone().unwrap_or_default(); - match event { - SessionEvent::Started { - backend, - session_id: id, - .. - } => { - if !backend.is_empty() { - backend_label = backend; - } - if session_id.is_none() { - session_id = id; - } - } - SessionEvent::TextDelta { text } => { - output.push_str(&text); - sink.emit(AgentNotification::Output { - session_id: current_session_id, - text, - is_final: false, - }); - } - SessionEvent::FinalText { text } => { - output = text.clone(); - sink.emit(AgentNotification::Output { - session_id: current_session_id, - text, - is_final: true, - }); - } - SessionEvent::Thinking { text } => { - thinking.push(text.clone()); - sink.emit(AgentNotification::Thinking { - session_id: current_session_id, - text, - }); - } - SessionEvent::ToolCall { - tool_name, - arguments, - server, - } => { - tool_calls.push(serde_json::json!({ - "tool_name": tool_name, - "arguments": arguments, - "server": server, - })); - sink.emit(AgentNotification::ToolCall { - session_id: current_session_id, - name: tool_name, - arguments, - server, - }); - } - SessionEvent::ToolResult { - tool_name, - output: result, - success, - } => { - tool_results.push(serde_json::json!({ - "tool_name": tool_name, - "output": result, - "success": success, - })); - sink.emit(AgentNotification::ToolResult { - session_id: current_session_id, - name: tool_name, - output: result, - success, - }); - } - SessionEvent::Artifact { - artifact_id, - metadata: artifact_metadata, - } => { - metadata.push(serde_json::json!({ - "artifact_id": artifact_id, - "metadata": artifact_metadata, - })); - } - SessionEvent::Metadata { metadata: meta } => { - metadata.push(meta); - } - SessionEvent::InteractionRequested { id, kind } => { - sink.emit(AgentNotification::InteractionRequested { - session_id: current_session_id, - interaction_id: id.clone(), - interaction_kind: kind.clone(), - payload: InteractionRequestPayload::default(), - expires_at: None, - }); - metadata.push(serde_json::json!({ - "interaction_requested": { "id": id, "kind": kind }, - })); - } - SessionEvent::InteractionResolved { id, decision } => { - metadata.push(serde_json::json!({ - "interaction_resolved": { "id": id, "decision": decision }, - })); - } - SessionEvent::Error { - message, - recoverable, - } => { - errors.push(message.clone()); - if !recoverable { - exit_code = 1; - } - sink.emit(AgentNotification::Error { - session_id: current_session_id, - message, - recoverable, - }); - } - SessionEvent::Finished { exit_code: code } => { - if let Some(code) = code { - exit_code = code; - } - break; - } - } - } - - drop(sink); - - let session_id = session_id.unwrap_or_default(); - let backend_label = if backend_label.is_empty() { - format!("gemini:{model_label}") - } else { - format!("{backend_label}:{model_label}") - }; - - Ok(AgentRunResponse { - session_id, - exit_code, - output, - metadata, - tool_calls, - tool_results, - thinking, - errors, - duration_ms: started.elapsed().as_millis() as u64, - backend: backend_label, - tokens_used: None, - decision_verdict: None, - }) - } -} - -#[async_trait] -impl ProviderBackend for GeminiProviderBackend { - fn manifest(&self) -> ProviderManifest { - ProviderManifest { - name: env!("CARGO_PKG_NAME").to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - description: env!("CARGO_PKG_DESCRIPTION").to_string(), - supported_models: vec![ - "gemini-3.1-pro-preview".to_string(), - "gemini-2.5-pro".to_string(), - "gemini-2.5-flash".to_string(), - "gemini-1.5-pro".to_string(), - "gemini-1.5-flash".to_string(), - ], - tool: "gemini".to_string(), - // The Gemini CLI itself is write-capable; whether a given workflow - // phase routes here is a separate policy concern handled by the - // Animus core (see `enforce_write_capable_phase_target`). - capabilities: ProviderCapabilities { - streaming: true, - resume: true, - cancellation: true, - write_capable: true, - mcp: true, - }, - } - } - - async fn run_agent(&self, request: AgentRunRequest) -> Result { - self.run_agent_streaming(request, NotificationSink::noop()) - .await - } - - async fn run_agent_streaming( - &self, - request: AgentRunRequest, - sink: NotificationSink, - ) -> Result { - let started = Instant::now(); - let session_request = self.build_session_request(&request); - let model_label = session_request.model.clone(); - let run = self - .session - .start_session(session_request) - .await - .map_err(|error| BackendError::SessionStartFailed(error.to_string()))?; - self.drain_events(run, started, model_label, sink).await - } - - async fn resume_agent( - &self, - request: AgentResumeRequest, - ) -> Result { - self.resume_agent_streaming(request, NotificationSink::noop()) - .await - } - - async fn resume_agent_streaming( - &self, - request: AgentResumeRequest, - sink: NotificationSink, - ) -> Result { - let started = Instant::now(); - let session_id = request - .session_id - .clone() - .ok_or_else(|| BackendError::RunFailed("resume requires a session_id".to_string()))?; - let session_request = self.build_session_request(&request); - let model_label = session_request.model.clone(); - let run = self - .session - .resume_session(session_request, &session_id) - .await - .map_err(|error| BackendError::SessionStartFailed(error.to_string()))?; - self.drain_events(run, started, model_label, sink).await - } - - async fn cancel_agent(&self, session_id: &str) -> Result<(), BackendError> { - self.session - .terminate_session(session_id) - .await - .map_err(|error| BackendError::Other(anyhow::anyhow!(error.to_string()))) - } - - async fn health(&self) -> Result { - match lookup_binary_in_path(&self.config.gemini_bin) { - Some(_) => Ok(HealthCheckResult { - status: HealthStatus::Healthy, - uptime_ms: None, - memory_usage_bytes: None, - last_error: None, - }), - None => Ok(HealthCheckResult { - status: HealthStatus::Unhealthy, - uptime_ms: None, - memory_usage_bytes: None, - last_error: Some(format!( - "gemini binary '{}' not found in PATH", - self.config.gemini_bin - )), - }), - } - } -} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 2834fb5..0000000 --- a/src/config.rs +++ /dev/null @@ -1,37 +0,0 @@ -use anyhow::Result; - -/// Runtime configuration for the Gemini provider plugin. -#[derive(Debug, Clone)] -pub struct GeminiConfig { - /// Name (or absolute path) of the Gemini CLI binary the provider should - /// spawn. Read from `GEMINI_BIN`; defaults to `"gemini"`. - pub gemini_bin: String, - /// Default model identifier when the `AgentRunRequest` doesn't specify - /// one. Read from `GEMINI_DEFAULT_MODEL`; defaults to - /// `"gemini-3.1-pro-preview"`. - pub default_model: String, -} - -impl GeminiConfig { - /// Build a config from environment variables, applying defaults for any - /// unset values. - pub fn from_env() -> Result { - let gemini_bin = std::env::var("GEMINI_BIN").unwrap_or_else(|_| "gemini".to_string()); - let default_model = std::env::var("GEMINI_DEFAULT_MODEL") - .unwrap_or_else(|_| "gemini-3.1-pro-preview".to_string()); - - Ok(Self { - gemini_bin, - default_model, - }) - } - - /// Helper for integration tests / embedders that want to construct a - /// config without going through env vars. - pub fn for_testing(gemini_bin: impl Into) -> Self { - Self { - gemini_bin: gemini_bin.into(), - default_model: "gemini-3.1-pro-preview".to_string(), - } - } -} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 4f8eff0..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Library surface for the `animus-provider-gemini` plugin. -//! -//! The binary entrypoint lives in `src/main.rs`. The modules below are -//! exposed so integration tests (and downstream embedders that want to wire -//! the Gemini backend without spawning a subprocess) can reach the -//! `ProviderBackend` implementation directly. - -pub mod backend; -pub mod config; diff --git a/src/main.rs b/src/main.rs index b48d64b..4ed65ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,20 @@ -use animus_plugin_protocol::{PluginInfo, PLUGIN_KIND_PROVIDER}; -use animus_plugin_runtime::provider_main_with_capabilities; -use animus_provider_gemini::backend::GeminiProviderBackend; -use animus_provider_gemini::config::GeminiConfig; +//! `animus-provider-gemini` — the Animus provider for Google Gemini. +//! +//! This is a thin wrapper over the shared ACP client (`animus-provider-acp`). +//! It advertises `provider_tool = "gemini"` and pins the harness to +//! `gemini --acp`, so the kernel routes Gemini models here exactly as before +//! while the plugin drives the Gemini CLI over the Agent Client Protocol +//! (structured streaming + a native permission callback) instead of scraping +//! stdout. Every tool call is gated through `animus agent approve-hook` by the +//! ACP client. + +use std::sync::Arc; + +use animus_plugin_runtime::{run_provider, ProviderInfo, SessionBackendProvider}; +use animus_provider_acp::backend::AcpSessionBackend; +use animus_provider_acp::config::AcpConfig; + +const DEFAULT_MODEL: &str = "gemini-2.5-flash"; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -12,19 +25,34 @@ async fn main() -> anyhow::Result<()> { .with_writer(std::io::stderr) .init(); - let config = GeminiConfig::from_env()?; - let backend = GeminiProviderBackend::new(config); + // `GEMINI_DEFAULT_MODEL` overrides the fallback model; `GEMINI_BIN` + // overrides the harness binary (default `gemini`). The harness is always + // driven in ACP mode (`--acp`). + let default_model = std::env::var("GEMINI_DEFAULT_MODEL") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_MODEL.to_string()); + let bin = std::env::var("GEMINI_BIN") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "gemini".to_string()); - let info = PluginInfo { - name: env!("CARGO_PKG_NAME").into(), - version: env!("CARGO_PKG_VERSION").into(), - plugin_kind: PLUGIN_KIND_PROVIDER.into(), - description: Some(env!("CARGO_PKG_DESCRIPTION").into()), - }; + let config = AcpConfig::for_harness("gemini", bin, ["--acp"], default_model.clone()); + let backend = Arc::new(AcpSessionBackend::new(config)); - let extra_capabilities = vec![]; + // `ProviderInfo` fields are `&'static str`; leak the (process-lifetime) + // default model so a `GEMINI_DEFAULT_MODEL` override is honored. + let default_model: &'static str = Box::leak(default_model.into_boxed_str()); - provider_main_with_capabilities(info, backend, extra_capabilities).await + let info = ProviderInfo { + plugin_name: env!("CARGO_PKG_NAME"), + plugin_version: env!("CARGO_PKG_VERSION"), + description: env!("CARGO_PKG_DESCRIPTION"), + default_tool: "gemini", + default_model, + }; + + run_provider(info, SessionBackendProvider::new(backend)).await } fn emit_manifest_if_requested() { @@ -50,44 +78,29 @@ fn emit_manifest_if_requested() { "env_required": [ { "name": "GEMINI_BIN", - "description": "Override the Gemini CLI binary path.", + "description": "Override the Gemini CLI binary (default `gemini`). Driven in ACP mode via `--acp`.", "required": false }, { "name": "GEMINI_DEFAULT_MODEL", - "description": "Fallback model used when the request omits a model.", + "description": "Fallback model used when an agent/run request omits a model.", "required": false }, { "name": "GEMINI_API_KEY", - "description": "Gemini API key forwarded to the Gemini CLI.", + "description": "API key for the Gemini harness when using API-key auth.", "sensitive": true, "required": false }, { "name": "GOOGLE_API_KEY", - "description": "Google API key forwarded to the Gemini CLI.", + "description": "Google API key alternative for the Gemini harness.", "sensitive": true, "required": false }, { - "name": "GOOGLE_GENAI_USE_VERTEXAI", - "description": "Enable Vertex AI mode for Google GenAI clients.", - "required": false - }, - { - "name": "GOOGLE_CLOUD_PROJECT", - "description": "Google Cloud project used by Vertex AI mode.", - "required": false - }, - { - "name": "GOOGLE_CLOUD_LOCATION", - "description": "Google Cloud region used by Vertex AI mode.", - "required": false - }, - { - "name": "GEMINI_CLI_SYSTEM_SETTINGS_PATH", - "description": "Override path for the Gemini CLI system settings file the session backend injects per run.", + "name": "ANIMUS_BIN", + "description": "Path to the `animus` binary used for the approve-hook approval gate (default: resolved on PATH).", "required": false } ] diff --git a/tests/contract.rs b/tests/contract.rs deleted file mode 100644 index 6b19aa1..0000000 --- a/tests/contract.rs +++ /dev/null @@ -1,456 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; - -use animus_plugin_protocol::HealthStatus; -use animus_provider_gemini::backend::GeminiProviderBackend; -use animus_provider_gemini::config::GeminiConfig; -use animus_provider_protocol::{ - AgentNotification, AgentRunRequest, NotificationSink, ProviderBackend, -}; -use animus_session_backend::{ - Result as SessionResult, SessionBackend, SessionBackendInfo, SessionBackendKind, - SessionCapabilities, SessionEvent, SessionRequest, SessionRun, SessionStability, -}; -use async_trait::async_trait; -use tokio::sync::mpsc; - -// --------------------------------------------------------------------- -// Fakes -// --------------------------------------------------------------------- - -struct FakeSession { - started: Arc>>, - resumed: Arc>>, - cancelled: Arc>>, - canned: Vec, -} - -impl FakeSession { - fn new(canned: Vec) -> Self { - Self { - started: Arc::new(Mutex::new(Vec::new())), - resumed: Arc::new(Mutex::new(Vec::new())), - cancelled: Arc::new(Mutex::new(Vec::new())), - canned, - } - } - - fn started_log(&self) -> Arc>> { - self.started.clone() - } - - fn resumed_log(&self) -> Arc>> { - self.resumed.clone() - } - - fn cancelled_log(&self) -> Arc>> { - self.cancelled.clone() - } - - async fn emit_run(&self) -> SessionRun { - let (tx, rx) = mpsc::channel(32); - for event in self.canned.clone() { - let _ = tx.send(event).await; - } - let _ = tx.send(SessionEvent::Finished { exit_code: Some(0) }).await; - drop(tx); - SessionRun { - session_id: Some("fake-session-id".to_string()), - events: rx, - selected_backend: "gemini-fake".to_string(), - fallback_reason: None, - pid: None, - } - } -} - -#[async_trait] -impl SessionBackend for FakeSession { - fn info(&self) -> SessionBackendInfo { - SessionBackendInfo { - kind: SessionBackendKind::GeminiSdk, - provider_tool: "gemini".to_string(), - stability: SessionStability::Experimental, - display_name: "Fake Gemini Backend".to_string(), - } - } - - fn capabilities(&self) -> SessionCapabilities { - SessionCapabilities { - supports_resume: true, - supports_terminate: true, - supports_permissions: true, - supports_mcp: true, - supports_tool_events: false, - supports_thinking_events: false, - supports_artifact_events: false, - supports_usage_metadata: true, - } - } - - async fn start_session(&self, request: SessionRequest) -> SessionResult { - self.started.lock().unwrap().push(request); - Ok(self.emit_run().await) - } - - async fn resume_session( - &self, - request: SessionRequest, - session_id: &str, - ) -> SessionResult { - self.resumed - .lock() - .unwrap() - .push((request, session_id.to_string())); - Ok(self.emit_run().await) - } - - async fn terminate_session(&self, session_id: &str) -> SessionResult<()> { - self.cancelled.lock().unwrap().push(session_id.to_string()); - Ok(()) - } -} - -// --------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------- - -fn run_request(model: Option<&str>, prompt: &str) -> AgentRunRequest { - AgentRunRequest { - session_id: None, - prompt: prompt.to_string(), - model: model.map(|s| s.to_string()), - system_prompt: None, - cwd: PathBuf::from("/tmp"), - project_root: None, - permission_mode: None, - timeout_secs: None, - env: HashMap::new(), - mcp_servers: None, - tools: None, - response_schema: None, - runtime_contract: None, - extras: HashMap::new(), - } -} - -fn resume_request(session_id: &str, prompt: &str) -> AgentRunRequest { - let mut request = run_request(Some("gemini-3.1-pro-preview"), prompt); - request.session_id = Some(session_id.to_string()); - request -} - -// --------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------- - -#[tokio::test] -async fn run_agent_via_fake_session() { - let fake = FakeSession::new(vec![SessionEvent::FinalText { - text: "hello".to_string(), - }]); - let started_log = fake.started_log(); - let backend = - GeminiProviderBackend::with_session(fake, GeminiConfig::for_testing("/usr/bin/true")); - - let response = backend - .run_agent(run_request(Some("gemini-3.1-pro-preview"), "ping")) - .await - .expect("run_agent should succeed"); - - assert!( - response.output.contains("hello"), - "expected output to contain 'hello', got {:?}", - response.output - ); - assert_eq!(response.exit_code, 0); - assert_eq!(response.session_id, "fake-session-id"); - assert!(response.backend.contains("gemini")); - - let started = started_log.lock().unwrap(); - assert_eq!(started.len(), 1); - assert_eq!(started[0].tool, "gemini"); - assert_eq!(started[0].model, "gemini-3.1-pro-preview"); - assert_eq!(started[0].prompt, "ping"); -} - -#[tokio::test] -async fn resume_agent_via_fake_session() { - let fake = FakeSession::new(vec![SessionEvent::FinalText { - text: "resumed".to_string(), - }]); - let resumed_log = fake.resumed_log(); - let backend = - GeminiProviderBackend::with_session(fake, GeminiConfig::for_testing("/usr/bin/true")); - - let response = backend - .resume_agent(resume_request("prior-session-xyz", "keep going")) - .await - .expect("resume_agent should succeed"); - - assert!(response.output.contains("resumed")); - - let resumed = resumed_log.lock().unwrap(); - assert_eq!(resumed.len(), 1); - assert_eq!(resumed[0].1, "prior-session-xyz"); -} - -#[tokio::test] -async fn cancel_agent_forwards_session_id() { - let fake = FakeSession::new(Vec::new()); - let cancelled_log = fake.cancelled_log(); - let backend = - GeminiProviderBackend::with_session(fake, GeminiConfig::for_testing("/usr/bin/true")); - - backend - .cancel_agent("session-to-cancel") - .await - .expect("cancel_agent should succeed"); - - let cancelled = cancelled_log.lock().unwrap(); - assert_eq!(cancelled.as_slice(), &["session-to-cancel".to_string()]); -} - -#[tokio::test] -async fn health_unhealthy_when_gemini_missing() { - let backend = GeminiProviderBackend::new(GeminiConfig::for_testing( - "/definitely/does/not/exist/animus-gemini-bin", - )); - let health = backend.health().await.expect("health should not error"); - assert_eq!(health.status, HealthStatus::Unhealthy); - assert!(health.last_error.is_some()); - let last_error = health.last_error.unwrap(); - assert!( - last_error.contains("not found"), - "expected error to mention 'not found', got {last_error:?}" - ); -} - -#[tokio::test] -async fn health_healthy_when_gemini_present() { - use std::fs; - use std::os::unix::fs::PermissionsExt; - - let tempdir = tempfile::tempdir().expect("create tempdir"); - let bin_path = tempdir.path().join("gemini"); - fs::write(&bin_path, "#!/bin/sh\nexit 0\n").expect("write stub binary"); - let mut perms = fs::metadata(&bin_path).unwrap().permissions(); - perms.set_mode(0o755); - fs::set_permissions(&bin_path, perms).unwrap(); - - let original_path = std::env::var("PATH").unwrap_or_default(); - let new_path = format!("{}:{}", tempdir.path().display(), original_path); - - // SAFETY: tests in this binary that touch PATH run sequentially because - // `cargo test` defaults to one thread for env-mutating cases is not - // guaranteed — but for this contract suite the env mutation only - // affects `which` lookups for `"gemini"`, and we restore it before - // returning. - std::env::set_var("PATH", &new_path); - - let backend = GeminiProviderBackend::new(GeminiConfig::for_testing("gemini")); - let health = backend.health().await.expect("health should not error"); - - std::env::set_var("PATH", original_path); - - assert_eq!(health.status, HealthStatus::Healthy); - assert!(health.last_error.is_none()); -} - -#[tokio::test] -async fn run_agent_streaming_emits_notifications_in_session_order() { - let canned = vec![ - SessionEvent::TextDelta { - text: "hel".to_string(), - }, - SessionEvent::Thinking { - text: "considering options".to_string(), - }, - SessionEvent::ToolCall { - tool_name: "shell".to_string(), - arguments: serde_json::json!({"cmd": "echo hi"}), - server: Some("local".to_string()), - }, - SessionEvent::ToolResult { - tool_name: "shell".to_string(), - output: serde_json::json!("hi\n"), - success: true, - }, - SessionEvent::Error { - message: "transient".to_string(), - recoverable: true, - }, - SessionEvent::FinalText { - text: "hello world".to_string(), - }, - ]; - - let fake = FakeSession::new(canned); - let backend = - GeminiProviderBackend::with_session(fake, GeminiConfig::for_testing("/usr/bin/true")); - - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - let recorded_clone = recorded.clone(); - let sink = NotificationSink::new(move |notification| { - recorded_clone.lock().unwrap().push(notification); - }); - - let response = backend - .run_agent_streaming(run_request(Some("gemini-3.1-pro-preview"), "go"), sink) - .await - .expect("streaming run should succeed"); - - assert_eq!(response.output, "hello world"); - assert_eq!(response.exit_code, 0); - assert_eq!(response.session_id, "fake-session-id"); - assert_eq!(response.tool_calls.len(), 1); - assert_eq!(response.tool_results.len(), 1); - assert_eq!(response.thinking, vec!["considering options".to_string()]); - assert_eq!(response.errors, vec!["transient".to_string()]); - - let frames = recorded.lock().unwrap(); - assert_eq!( - frames.len(), - 6, - "expected 6 streaming notifications, got {:?}", - frames - ); - - match &frames[0] { - AgentNotification::Output { - session_id, - text, - is_final, - } => { - assert_eq!(session_id, "fake-session-id"); - assert_eq!(text, "hel"); - assert!(!*is_final); - } - other => panic!("expected Output, got {other:?}"), - } - match &frames[1] { - AgentNotification::Thinking { session_id, text } => { - assert_eq!(session_id, "fake-session-id"); - assert_eq!(text, "considering options"); - } - other => panic!("expected Thinking, got {other:?}"), - } - match &frames[2] { - AgentNotification::ToolCall { - session_id, - name, - arguments, - server, - } => { - assert_eq!(session_id, "fake-session-id"); - assert_eq!(name, "shell"); - assert_eq!(arguments, &serde_json::json!({"cmd": "echo hi"})); - assert_eq!(server.as_deref(), Some("local")); - } - other => panic!("expected ToolCall, got {other:?}"), - } - match &frames[3] { - AgentNotification::ToolResult { - session_id, - name, - output, - success, - } => { - assert_eq!(session_id, "fake-session-id"); - assert_eq!(name, "shell"); - assert_eq!(output, &serde_json::json!("hi\n")); - assert!(*success); - } - other => panic!("expected ToolResult, got {other:?}"), - } - match &frames[4] { - AgentNotification::Error { - session_id, - message, - recoverable, - } => { - assert_eq!(session_id, "fake-session-id"); - assert_eq!(message, "transient"); - assert!(*recoverable); - } - other => panic!("expected Error, got {other:?}"), - } - match &frames[5] { - AgentNotification::Output { - session_id, - text, - is_final, - } => { - assert_eq!(session_id, "fake-session-id"); - assert_eq!(text, "hello world"); - assert!(*is_final); - } - other => panic!("expected final Output, got {other:?}"), - } -} - -#[tokio::test] -async fn run_agent_default_path_emits_no_notifications() { - let fake = FakeSession::new(vec![SessionEvent::FinalText { - text: "done".to_string(), - }]); - let backend = - GeminiProviderBackend::with_session(fake, GeminiConfig::for_testing("/usr/bin/true")); - - let response = backend - .run_agent(run_request(Some("gemini-3.1-pro-preview"), "ping")) - .await - .expect("run_agent should succeed"); - - assert_eq!(response.output, "done"); -} - -#[tokio::test] -async fn manifest_capabilities_sanity() { - let backend = GeminiProviderBackend::new(GeminiConfig::for_testing("gemini")); - let manifest = backend.manifest(); - - assert_eq!(manifest.name, "animus-provider-gemini"); - assert_eq!(manifest.tool, "gemini"); - assert!(!manifest.version.is_empty()); - assert!(!manifest.description.is_empty()); - assert!(manifest - .supported_models - .iter() - .any(|m| m == "gemini-3.1-pro-preview")); - - let caps = manifest.capabilities; - assert!(caps.streaming); - assert!(caps.resume); - assert!(caps.cancellation); - assert!(caps.write_capable); - assert!(caps.mcp); -} - -#[tokio::test] -async fn run_agent_passes_mcp_servers_through_to_session_request() { - let fake = FakeSession::new(vec![SessionEvent::FinalText { - text: "done".to_string(), - }]); - let started_log = fake.started_log(); - let backend = - GeminiProviderBackend::with_session(fake, GeminiConfig::for_testing("/usr/bin/true")); - - let servers = serde_json::json!({ - "docs": { "command": "npx", "args": ["-y", "docs-mcp"] } - }); - let mut request = run_request(None, "go"); - request.mcp_servers = Some(servers.clone()); - - backend - .run_agent(request) - .await - .expect("run_agent should succeed"); - - let started = started_log.lock().unwrap(); - assert_eq!(started.len(), 1); - assert_eq!(started[0].mcp_servers, Some(servers)); - assert_eq!(started[0].mcp_endpoint, None); - assert!(started[0].extras.get("mcp_servers").is_none()); -}