diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index c9b70429a1..1f63b76b46 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -55,6 +55,12 @@ BUZZ_AGENT_PROVIDER=databricks \ DATABRICKS_HOST=https://dbc-...cloud.databricks.com \ DATABRICKS_MODEL=goose-claude-4-6-sonnet \ ./target/release/buzz-agent + +# Or Gemini via Google's OpenAI-compatible surface +BUZZ_AGENT_PROVIDER=gemini \ +GEMINI_API_KEY=AIza... \ +GEMINI_MODEL=gemini-2.5-pro \ + ./target/release/buzz-agent ``` That's the whole setup. The agent reads JSON-RPC frames from stdin, writes them to stdout, and logs to stderr. @@ -129,7 +135,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro | Variable | Default | Notes | |---|---|---| -| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. | +| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `gemini`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. | | `ANTHROPIC_API_KEY` | — | Required when provider=anthropic. | | `ANTHROPIC_MODEL` | — | Required when provider=anthropic. | | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | | @@ -138,6 +144,9 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `OPENAI_COMPAT_MODEL` | — | Required when provider=openai. | | `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, OpenRouter, Ollama, etc. | | `OPENAI_COMPAT_API` | `auto` | `auto` \| `chat` \| `responses`. `auto` picks Responses for `*.openai.com`, Chat Completions everywhere else. | +| `GEMINI_API_KEY` | — | Required when provider=gemini. | +| `GEMINI_MODEL` | — | Required when provider=gemini (unless `BUZZ_AGENT_MODEL` is set, which overrides it). | +| `GEMINI_BASE_URL` | `https://generativelanguage.googleapis.com/v1beta/openai` | Google's OpenAI-compatible surface. Chat Completions is always used; native `generateContent` is not. | | `DATABRICKS_HOST` | — | Required when provider=databricks or provider=databricks_v2. | | `DATABRICKS_MODEL` | — | Required when provider=databricks or provider=databricks_v2. | | `DATABRICKS_TOKEN` | — | Optional static bearer escape hatch. If unset, Databricks uses browser OAuth + refresh cache. | @@ -169,10 +178,13 @@ Everything is environment variables. No flags, no config files. (We are a subpro | Ollama | `openai` | `POST {base}/chat/completions` | llama3.1, qwen2.5-coder | | OpenRouter | `openai` | `POST {base}/chat/completions` | anything they route | | Block Gateway | `openai` | `POST {base}/chat/completions` | gpt-5, claude | +| Gemini | `gemini` | `POST {base}/chat/completions` | gemini-2.5-pro, gemini-2.5-flash | | Databricks | `databricks` | `POST {host}/serving-endpoints/{model}/invocations` | goose-claude-4-6-sonnet | | Databricks AI Gateway v2 | `databricks_v2` | `POST {host}/ai-gateway/{provider}/v1/...` | databricks-gpt-5-5, databricks-claude-opus-4-7 | -If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, or `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, the agent returns an error — there is no implicit fallback to another provider. +If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, `BUZZ_AGENT_PROVIDER=openai` without `OPENAI_COMPAT_API_KEY`, or `BUZZ_AGENT_PROVIDER=gemini` without `GEMINI_API_KEY`, the agent returns an error — there is no implicit fallback to another provider. + +`provider=gemini` targets Google's OpenAI-compatible surface (`GEMINI_BASE_URL`, default `https://generativelanguage.googleapis.com/v1beta/openai`) and always speaks **Chat Completions** (`POST {base}/chat/completions`) with a static `GEMINI_API_KEY` bearer — it never routes to `/responses`, and native `generateContent` is not used. `provider=openai` speaks two HTTP dialects: the [Responses API](https://platform.openai.com/docs/api-reference/responses) (`/v1/responses`, required for GPT-5 / o-series tool-calling on OpenAI's own service) and the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) (`/chat/completions`, the broadly-supported OpenAI-compatible wire format). diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 730e87b2e8..4dc6790409 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -679,6 +679,7 @@ pub(crate) fn push_hook_outputs_as_tool_results( provider_id: provider_id.clone(), name: tool_name, arguments: serde_json::json!({}), + extra_content: None, }], }); history.push(HistoryItem::ToolResult(ToolResult { diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index f06849c7d1..bcb49cb697 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -658,10 +658,20 @@ pub const HANDOFF_MAX_TOOL_NAMES: usize = 20; const DEFAULT_SYSTEM_PROMPT: &str = "You are buzz-agent. Use the provided tools to act. Tool calls are your only output."; +/// Default base URL for `Provider::Gemini`: Google's OpenAI-compatible surface. +/// Chat Completions and `/models` live under this prefix. Overridable via +/// `GEMINI_BASE_URL`. Native `generateContent` is intentionally not used. +pub(crate) const GEMINI_DEFAULT_BASE_URL: &str = + "https://generativelanguage.googleapis.com/v1beta/openai"; + #[derive(Debug, Clone, Copy, PartialEq)] pub enum Provider { Anthropic, OpenAi, + /// Google Gemini via its OpenAI-compatible surface. Reuses the OpenAI Chat + /// Completions body builder/parser and a static `GEMINI_API_KEY` bearer; + /// only the env var names and default base URL differ from `OpenAi`. + Gemini, /// Databricks model serving. Routes to `{base_url}/serving-endpoints/{model}/invocations` /// with a dynamically-acquired bearer (OAuth 2.0 PKCE, or static `DATABRICKS_TOKEN`). /// Wire format is OpenAI-chat-compatible — reuses the same body builder and parser. @@ -740,6 +750,7 @@ impl Config { env("BUZZ_AGENT_PROVIDER").as_deref(), env("ANTHROPIC_API_KEY").as_deref(), env("OPENAI_COMPAT_API_KEY").as_deref(), + env("GEMINI_API_KEY").as_deref(), )?; // Universal model override — takes priority over provider-specific model @@ -775,6 +786,16 @@ impl Config { env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"), parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?, ), + // Gemini's OpenAI-compatible surface is Chat Completions only, so the + // API shape is pinned to Chat rather than Auto (there is no /responses + // endpoint to upgrade to on generativelanguage.googleapis.com). + Provider::Gemini => ( + req("GEMINI_API_KEY")?, + resolve_model(buzz_agent_model.as_deref(), env("GEMINI_MODEL").as_deref()) + .ok_or_else(|| "config: GEMINI_MODEL required".to_string())?, + env_or("GEMINI_BASE_URL", GEMINI_DEFAULT_BASE_URL), + OpenAiApi::Chat, + ), Provider::Databricks | Provider::DatabricksV2 => ( env("DATABRICKS_TOKEN").unwrap_or_default(), resolve_model(buzz_agent_model.as_deref(), databricks_model.as_deref()) @@ -983,6 +1004,7 @@ fn resolve_provider( requested: Option<&str>, anthropic_key: Option<&str>, openai_key: Option<&str>, + gemini_key: Option<&str>, ) -> Result { match requested.map(str::trim).filter(|s| !s.is_empty()) { Some(raw) => { @@ -996,6 +1018,10 @@ fn resolve_provider( "openai" | "openai-compat" => Err( "config: OPENAI_COMPAT_API_KEY required".into(), ), + "gemini" if present_nonempty(gemini_key) => Ok(Provider::Gemini), + "gemini" => Err( + "config: GEMINI_API_KEY required".into(), + ), "databricks" => Ok(Provider::Databricks), "databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2), _ => Err(format!( @@ -1004,7 +1030,7 @@ fn resolve_provider( } } None => Err( - "config: BUZZ_AGENT_PROVIDER is required — set it to your provider (e.g. anthropic, openai, databricks)".into(), + "config: BUZZ_AGENT_PROVIDER is required — set it to your provider (e.g. anthropic, openai, gemini, databricks)".into(), ), } } @@ -1215,29 +1241,42 @@ mod tests { #[test] fn resolve_provider_keeps_requested_provider_when_token_present() { assert_eq!( - resolve_provider(Some("anthropic"), Some("sk-ant"), None,).unwrap(), + resolve_provider(Some("anthropic"), Some("sk-ant"), None, None).unwrap(), Provider::Anthropic ); assert_eq!( - resolve_provider(Some("openai"), None, Some("sk-openai"),).unwrap(), + resolve_provider(Some("openai"), None, Some("sk-openai"), None).unwrap(), Provider::OpenAi ); + assert_eq!( + resolve_provider(Some("gemini"), None, None, Some("AIza-key")).unwrap(), + Provider::Gemini + ); + // Case-insensitive, like the other providers. + assert_eq!( + resolve_provider(Some("Gemini"), None, None, Some("AIza-key")).unwrap(), + Provider::Gemini + ); } #[test] fn resolve_provider_errors_when_requested_provider_key_missing() { // No fallback — missing key returns an error regardless of Databricks availability. - let err = resolve_provider(Some("anthropic"), None, None).unwrap_err(); + let err = resolve_provider(Some("anthropic"), None, None, None).unwrap_err(); assert!(err.contains("ANTHROPIC_API_KEY required"), "{err}"); - let err = resolve_provider(Some("openai-compat"), None, Some(" ")).unwrap_err(); + let err = resolve_provider(Some("openai-compat"), None, Some(" "), None).unwrap_err(); assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}"); + + // Whitespace-only Gemini key is treated as absent. + let err = resolve_provider(Some("gemini"), None, None, Some(" ")).unwrap_err(); + assert!(err.contains("GEMINI_API_KEY required"), "{err}"); } #[test] fn resolve_provider_errors_when_provider_env_absent() { // No implicit inference — absent BUZZ_AGENT_PROVIDER is an error. - let err = resolve_provider(None, None, None).unwrap_err(); + let err = resolve_provider(None, None, None, None).unwrap_err(); assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}"); } @@ -1247,19 +1286,19 @@ mod tests { // When BUZZ_AGENT_PROVIDER=databricks, resolve_provider succeeds regardless // of DATABRICKS_HOST/MODEL (those are validated later in from_env()). assert_eq!( - resolve_provider(Some("databricks"), None, None).unwrap(), + resolve_provider(Some("databricks"), None, None, None).unwrap(), Provider::Databricks ); // Missing key for other providers still errors — no Databricks fallback. - let err = resolve_provider(Some("openai"), None, None).unwrap_err(); + let err = resolve_provider(Some("openai"), None, None, None).unwrap_err(); assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}"); - let err = resolve_provider(None, None, None).unwrap_err(); + let err = resolve_provider(None, None, None, None).unwrap_err(); assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}"); } #[test] fn resolve_provider_unsupported_error_preserves_user_casing() { - let err = resolve_provider(Some("OpenAIish"), None, None).unwrap_err(); + let err = resolve_provider(Some("OpenAIish"), None, None, None).unwrap_err(); assert!(err.contains("BUZZ_AGENT_PROVIDER=OpenAIish")); } @@ -1299,6 +1338,37 @@ mod tests { assert!(result.is_none()); } + #[test] + fn gemini_default_base_url_is_openai_compat_surface() { + // The default base must point at Google's OpenAI-compatible surface + // (v1beta/openai), not the native generateContent API. from_env() feeds + // this const to env_or("GEMINI_BASE_URL", …). + assert_eq!( + GEMINI_DEFAULT_BASE_URL, + "https://generativelanguage.googleapis.com/v1beta/openai" + ); + // Sanity: the compat surface must not be treated as a first-party OpenAI + // host, or Auto-mode dispatch would try to upgrade it to /responses. + assert!(!is_openai_host(GEMINI_DEFAULT_BASE_URL)); + } + + #[test] + fn gemini_model_resolution_prefers_buzz_agent_model_override() { + // from_env() resolves Gemini's model as + // resolve_model(BUZZ_AGENT_MODEL, GEMINI_MODEL): the universal override wins. + assert_eq!( + resolve_model(Some("gemini-2.5-pro"), Some("gemini-1.5-flash")).as_deref(), + Some("gemini-2.5-pro"), + "BUZZ_AGENT_MODEL must override GEMINI_MODEL" + ); + // Falls back to GEMINI_MODEL when no universal override is set. + assert_eq!( + resolve_model(None, Some("gemini-1.5-flash")).as_deref(), + Some("gemini-1.5-flash"), + "GEMINI_MODEL must be used when BUZZ_AGENT_MODEL is unset" + ); + } + #[test] fn parse_thinking_effort_round_trips_all_values() { for (raw, expected) in [ @@ -1922,6 +1992,29 @@ mod tests { } } + #[test] + fn validate_accepts_all_efforts_for_gemini() { + // Gemini rides the OpenAI-shaped Chat path, so effort validation must + // behave exactly like OpenAI: none/minimal are NOT rejected at startup + // (only pure Anthropic rejects them); availability is normalized at + // request-build time via normalize_effort_for_openai_route. + for effort in [ + ThinkingEffort::None, + ThinkingEffort::Minimal, + ThinkingEffort::Low, + ThinkingEffort::Medium, + ThinkingEffort::High, + ThinkingEffort::XHigh, + ThinkingEffort::Max, + ] { + let cfg = make_config_for_validation(Provider::Gemini, Some(effort)); + assert!( + cfg.validate().is_ok(), + "Gemini must accept {effort:?} at startup, consistent with the OpenAI Chat path" + ); + } + } + #[test] fn validate_accepts_all_efforts_for_databricks() { // Legacy Databricks effort support is model-dependent and normalized at request build time. diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 8a774fdf24..2ae5f786c9 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -89,7 +89,7 @@ impl Llm { .await?; parse_anthropic(v) } - Provider::OpenAi | Provider::Databricks => { + Provider::OpenAi | Provider::Databricks | Provider::Gemini => { self.openai_request(cfg, effective_model, |use_responses| { // Normalize effort for model-specific availability. Startup no longer rejects // `max` for pure OpenAI/Databricks; this per-model table is the single authority @@ -178,7 +178,7 @@ impl Llm { }); Ok(parse_anthropic(self.post_anthropic(cfg, &body).await?)?.text) } - Provider::OpenAi | Provider::Databricks => { + Provider::OpenAi | Provider::Databricks | Provider::Gemini => { let r = self .openai_request(cfg, effective_model, |use_responses| { if use_responses { @@ -274,9 +274,11 @@ impl Llm { where F: FnMut(bool) -> (Value, OpenAiParse) + Send, { - let use_responses = self.auto_upgraded.load(Ordering::Relaxed) - || matches!(cfg.openai_api, OpenAiApi::Responses) - || matches!(cfg.openai_api, OpenAiApi::Auto) && is_openai_host(&cfg.base_url); + let use_responses = should_use_responses( + cfg.openai_api, + &cfg.base_url, + self.auto_upgraded.load(Ordering::Relaxed), + ); if use_responses { let (b, p) = build(true); @@ -387,6 +389,26 @@ impl Llm { } } +/// Decide whether an OpenAI-family request targets `/responses` instead of +/// `/chat/completions`. +/// +/// `OpenAiApi::Chat` is authoritative: a Chat-pinned provider (e.g. Gemini's +/// OpenAI-compatible surface, which has no `/responses` endpoint) can NEVER be +/// routed to `/responses`, regardless of the `auto_upgraded` latch or the host +/// heuristic. This is what keeps Gemini dispatch on Chat Completions even if the +/// auto-upgrade heuristics change later. +/// +/// `Responses` always uses `/responses`. `Auto` upgrades once the provider has +/// signalled it (the `auto_upgraded` latch, set only under `Auto`) or when the +/// base URL is a first-party OpenAI host. +fn should_use_responses(api: OpenAiApi, base_url: &str, auto_upgraded: bool) -> bool { + match api { + OpenAiApi::Chat => false, + OpenAiApi::Responses => true, + OpenAiApi::Auto => auto_upgraded || is_openai_host(base_url), + } +} + fn anthropic_body( cfg: &Config, system_prompt: &str, @@ -510,11 +532,18 @@ fn openai_body( let calls: Vec = tool_calls .iter() .map(|c| { - json!({ + let mut call = json!({ "id": c.provider_id, "type": "function", "function": { "name": c.name, "arguments": serde_json::to_string(&c.arguments) - .unwrap_or_else(|_| "{}".into()) } }) + .unwrap_or_else(|_| "{}".into()) } }); + // Echo back the provider's opaque `extra_content` + // (Gemini 3.x requires the `thought_signature` here + // or it rejects the follow-up turn with HTTP 400). + if let Some(extra) = &c.extra_content { + call["extra_content"] = extra.clone(); + } + call }) .collect(); msg.insert("tool_calls".into(), Value::Array(calls)); @@ -940,11 +969,12 @@ fn parse_openai(v: Value) -> Result { let raw = f.get("arguments").and_then(Value::as_str).unwrap_or("{}"); let args: Value = serde_json::from_str(raw) .map_err(|e| AgentError::Llm(format!("tool_call.arguments not valid JSON: {e}")))?; - tool_calls.push(make_tool_call( - str_field(tc, "id"), - str_field(f, "name"), - args, - )?); + let mut call = make_tool_call(str_field(tc, "id"), str_field(f, "name"), args)?; + // Preserve the opaque `extra_content` (Gemini 3.x `thought_signature`) + // so it can be echoed back on the next turn. Absent for providers + // that don't emit it. + call.extra_content = tc.get("extra_content").cloned(); + tool_calls.push(call); } } let input_tokens = openai_chat_input_tokens(&v); @@ -976,6 +1006,7 @@ fn make_tool_call(id: String, name: String, args: Value) -> Result Result, AgentError> { match cfg.provider { - Provider::Anthropic | Provider::OpenAi => { + Provider::Anthropic | Provider::OpenAi | Provider::Gemini => { Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone()))) } Provider::Databricks | Provider::DatabricksV2 => { @@ -1206,6 +1239,54 @@ mod tests { } } + #[test] + fn chat_pinned_never_uses_responses_regardless_of_host_or_latch() { + // Gemini (and any Chat-pinned provider) must stay on /chat/completions. + // Neither a first-party OpenAI host nor a set auto-upgrade latch may flip + // it to /responses — Chat is authoritative. This guards Gemini dispatch + // against future changes to the host/auto heuristics. + for base in [ + "https://generativelanguage.googleapis.com/v1beta/openai", + "https://api.openai.com/v1", // even a real OpenAI host stays on Chat + ] { + for auto_upgraded in [false, true] { + assert!( + !should_use_responses(OpenAiApi::Chat, base, auto_upgraded), + "Chat must never route to /responses (base={base}, auto_upgraded={auto_upgraded})" + ); + } + } + } + + #[test] + fn responses_pinned_always_uses_responses() { + assert!(should_use_responses( + OpenAiApi::Responses, + "http://example.invalid", + false + )); + } + + #[test] + fn auto_upgrades_on_openai_host_or_latch_only() { + // Auto: /responses only when the latch is set or the base is an OpenAI host. + assert!(should_use_responses( + OpenAiApi::Auto, + "https://api.openai.com/v1", + false + )); + assert!(should_use_responses( + OpenAiApi::Auto, + "http://example.invalid", + true + )); + assert!(!should_use_responses( + OpenAiApi::Auto, + "http://example.invalid", + false + )); + } + fn image_history() -> Vec { vec![ HistoryItem::User("describe the image".into()), @@ -1215,6 +1296,7 @@ mod tests { provider_id: "toolu_1".into(), name: "dev__view_image".into(), arguments: serde_json::json!({"source":"x.png"}), + extra_content: None, }], }, HistoryItem::ToolResult(ToolResult { @@ -1264,6 +1346,7 @@ mod tests { provider_id: "call_abc".into(), name: "dev__shell".into(), arguments: serde_json::json!({"command": "ls"}), + extra_content: None, }], }, HistoryItem::ToolResult(ToolResult { @@ -1362,6 +1445,7 @@ mod tests { provider_id: "call_x".into(), name: "t".into(), arguments: serde_json::json!({}), + extra_content: None, }], }, ]; @@ -1555,11 +1639,13 @@ mod tests { provider_id: "toolu_a".into(), name: "dev__view_image".into(), arguments: serde_json::json!({"source": "a.png"}), + extra_content: None, }, ToolCall { provider_id: "toolu_b".into(), name: "dev__view_image".into(), arguments: serde_json::json!({"source": "b.png"}), + extra_content: None, }, ], }, @@ -1811,6 +1897,124 @@ mod tests { assert_eq!(body["reasoning_effort"], "medium"); } + #[test] + fn parse_openai_captures_tool_call_extra_content() { + // Gemini 3.x returns a `thought_signature` on the tool call's + // `extra_content`. It must be captured verbatim so it can round-trip. + let v = serde_json::json!({ + "choices": [{ + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": { "name": "dev__shell", "arguments": "{\"command\":\"ls\"}" }, + "extra_content": { "google": { "thought_signature": "SIG123" } } + }] + } + }] + }); + let resp = parse_openai(v).unwrap(); + assert_eq!(resp.tool_calls.len(), 1); + assert_eq!( + resp.tool_calls[0].extra_content, + Some(serde_json::json!({ "google": { "thought_signature": "SIG123" } })) + ); + } + + #[test] + fn parse_openai_tool_call_without_extra_content_is_none() { + // Standard OpenAI / Anthropic-compat hosts omit `extra_content`. + let v = serde_json::json!({ + "choices": [{ + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": { "name": "dev__shell", "arguments": "{}" } + }] + } + }] + }); + let resp = parse_openai(v).unwrap(); + assert_eq!(resp.tool_calls[0].extra_content, None); + } + + #[test] + fn openai_body_roundtrips_tool_call_extra_content() { + // The captured `extra_content` must be echoed back on the replayed + // assistant turn, or Gemini rejects the follow-up with HTTP 400. + let sig = serde_json::json!({ "google": { "thought_signature": "SIG123" } }); + let history = vec![ + HistoryItem::User("call the tool".into()), + HistoryItem::Assistant { + text: String::new(), + tool_calls: vec![ToolCall { + provider_id: "call_1".into(), + name: "dev__shell".into(), + arguments: serde_json::json!({ "command": "ls" }), + extra_content: Some(sig.clone()), + }], + }, + HistoryItem::ToolResult(ToolResult { + provider_id: "call_1".into(), + content: vec![ToolResultContent::Text("file.txt".into())], + is_error: false, + }), + ]; + let body = openai_body( + &cfg(Provider::Gemini), + "system", + &history, + &[], + "model", + None, + ); + // Assistant message is messages[2] (system, user, assistant, tool). + let assistant = &body["messages"][2]; + assert_eq!(assistant["role"], "assistant"); + assert_eq!(assistant["tool_calls"][0]["extra_content"], sig); + } + + #[test] + fn openai_body_omits_extra_content_when_absent() { + // A tool call with no `extra_content` must not emit the key at all. + let history = vec![ + HistoryItem::User("call the tool".into()), + HistoryItem::Assistant { + text: String::new(), + tool_calls: vec![ToolCall { + provider_id: "call_1".into(), + name: "dev__shell".into(), + arguments: serde_json::json!({ "command": "ls" }), + extra_content: None, + }], + }, + HistoryItem::ToolResult(ToolResult { + provider_id: "call_1".into(), + content: vec![ToolResultContent::Text("file.txt".into())], + is_error: false, + }), + ]; + let body = openai_body( + &cfg(Provider::OpenAi), + "system", + &history, + &[], + "model", + None, + ); + assert!( + body["messages"][2]["tool_calls"][0] + .get("extra_content") + .is_none(), + "extra_content must be omitted when the tool call has none" + ); + } + #[test] fn responses_body_omits_reasoning_when_effort_none() { let body = responses_body( diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index d29e975e03..48de247ee9 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -108,6 +108,14 @@ pub struct ToolCall { pub provider_id: String, pub name: String, pub arguments: Value, + /// Opaque provider round-trip payload from the OpenAI-compat `extra_content` + /// field on a tool call. Gemini 3.x returns a `thought_signature` here and + /// *requires* it to be echoed back with the same tool call on the following + /// turn — otherwise the request is rejected with HTTP 400 + /// ("Function call is missing a thought_signature"). Preserved verbatim and + /// re-attached when replaying assistant history. `None` for providers that + /// don't emit it (Anthropic, standard OpenAI). + pub extra_content: Option, } #[derive(Debug, Clone)] diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 1963acc3bc..6a39463701 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -173,7 +173,12 @@ const overrides = new Map([ // Windows Doctor install fix: cli_install_commands_windows field added to test stubs. // team-instructions-first-class: ManagedAgentRecord fixture gains the new // team_id field (+1 line). - ["src-tauri/src/managed_agents/readiness.rs", 1765], + // gemini-goose-provider: the accumulated `mod tests` module was extracted to + // readiness_tests.rs (following the agent_models_tests.rs precedent) so the + // production file (readiness.rs) is back under the default ceiling. The + // historical test debt — plus the goose `google` (native Gemini) provider + // readiness coverage — now lives in that test-only file. + ["src-tauri/src/managed_agents/readiness_tests.rs", 1125], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the // harness-persona-sync `harnessOverride` create-input bit — load-bearing @@ -442,7 +447,11 @@ const overrides = new Map([ // (if let Some(provider_update) = input.provider { record.provider = provider_update; }). // +8: harness_override thread-through in update_managed_agent so a deliberate // Custom pin routes to update_time_agent_command_override (comment + call). - ["src-tauri/src/commands/agent_models.rs", 1079], + // +15: gemini provider — is_gemini_provider + provider-aware base-URL/api-key + // resolution (openai_compatible_base_url_env / _api_key_env) so Gemini reuses + // the OpenAI-compatible /models discovery path with GEMINI_BASE_URL/GEMINI_API_KEY. + // Load-bearing feature growth; queued to split with the rest of this list. + ["src-tauri/src/commands/agent_models.rs", 1095], // global-agent-config: get_agent_config_surface / write_agent_config_field / // put_agent_session_config commands + GlobalAgentConfig serde types. New file // in this PR; queued to split with the command module refactor. diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 97772b12a5..d2e2cea9c6 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -319,20 +319,54 @@ fn is_openai_compatible_provider(provider: Option<&str>) -> bool { .map(str::trim) .map(str::to_ascii_lowercase) .as_deref(), - Some("openai" | "openai-compat") + Some("openai" | "openai-compat" | "gemini") ) } +fn is_gemini_provider(provider: Option<&str>) -> bool { + provider + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref() + == Some("gemini") +} + +/// Base-URL env var + default for an OpenAI-compatible provider. Gemini reuses +/// the discovery path but reads `GEMINI_BASE_URL`, defaulting to Google's +/// OpenAI-compatible surface. +fn openai_compatible_base_url_env(provider: Option<&str>) -> (&'static str, &'static str) { + if is_gemini_provider(provider) { + ( + "GEMINI_BASE_URL", + "https://generativelanguage.googleapis.com/v1beta/openai", + ) + } else { + ("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1") + } +} + +/// API-key env var for an OpenAI-compatible provider (Gemini uses `GEMINI_API_KEY`). +fn openai_compatible_api_key_env(provider: Option<&str>) -> &'static str { + if is_gemini_provider(provider) { + "GEMINI_API_KEY" + } else { + "OPENAI_COMPAT_API_KEY" + } +} + #[cfg(test)] -fn openai_compatible_models_url(env: &BTreeMap) -> String { - let base_url = env_value(env, "OPENAI_COMPAT_BASE_URL") - .unwrap_or_else(|| "https://api.openai.com/v1".to_string()); +fn openai_compatible_models_url(env: &BTreeMap, provider: Option<&str>) -> String { + let (key, default) = openai_compatible_base_url_env(provider); + let base_url = env_value(env, key).unwrap_or_else(|| default.to_string()); format!("{}/models", base_url.trim_end_matches('/')) } -fn openai_compatible_models_url_for_discovery(env: &BTreeMap) -> String { - let base_url = env_or_process_value(env, "OPENAI_COMPAT_BASE_URL") - .unwrap_or_else(|| "https://api.openai.com/v1".to_string()); +fn openai_compatible_models_url_for_discovery( + env: &BTreeMap, + provider: Option<&str>, +) -> String { + let (key, default) = openai_compatible_base_url_env(provider); + let base_url = env_or_process_value(env, key).unwrap_or_else(|| default.to_string()); format!("{}/models", base_url.trim_end_matches('/')) } @@ -490,17 +524,18 @@ async fn discover_openai_compatible_models( return Ok(None); } + let api_key_env = openai_compatible_api_key_env(provider); let api_key = if relay_mesh { crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER.to_string() } else { - env_or_process_value(env, "OPENAI_COMPAT_API_KEY") - .ok_or_else(|| "config: OPENAI_COMPAT_API_KEY required".to_string())? + env_or_process_value(env, api_key_env) + .ok_or_else(|| format!("config: {api_key_env} required"))? }; - let redaction_env = redaction_env_with_value(env, "OPENAI_COMPAT_API_KEY", &api_key); + let redaction_env = redaction_env_with_value(env, api_key_env, &api_key); let url = if relay_mesh { format!("{}/models", crate::managed_agents::RELAY_MESH_API_BASE_URL) } else { - openai_compatible_models_url_for_discovery(env) + openai_compatible_models_url_for_discovery(env, provider) }; let response = client .get(&url) diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 0f99927c9e..31be8f4f7e 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -100,11 +100,60 @@ fn openai_compat_model_normalization_preserves_provider_specific_ids() { #[test] fn openai_models_url_uses_openai_default_base_url() { assert_eq!( - openai_compatible_models_url(&BTreeMap::new()), + openai_compatible_models_url(&BTreeMap::new(), Some("openai")), "https://api.openai.com/v1/models" ); } +#[test] +fn gemini_models_url_uses_google_openai_compatible_default_base_url() { + assert_eq!( + openai_compatible_models_url(&BTreeMap::new(), Some("gemini")), + "https://generativelanguage.googleapis.com/v1beta/openai/models" + ); +} + +#[test] +fn gemini_models_url_honors_gemini_base_url_override() { + let env = BTreeMap::from([( + "GEMINI_BASE_URL".to_string(), + "https://proxy.example/v1beta/openai/".to_string(), + )]); + assert_eq!( + openai_compatible_models_url(&env, Some("gemini")), + "https://proxy.example/v1beta/openai/models" + ); +} + +#[test] +fn gemini_is_openai_compatible_but_not_filtered_like_openai() { + // Routed through the OpenAI-compatible discovery path... + assert!(is_openai_compatible_provider(Some("gemini"))); + assert!(is_gemini_provider(Some("gemini"))); + // ...but Gemini model IDs must pass through unfiltered (only pure "openai" + // applies the GPT text-model filter). + let models = normalize_openai_compatible_models( + OpenAiModelListResponse { + data: vec![ + OpenAiModelListItem { + id: "gemini-2.5-flash".to_string(), + created: Some(2), + }, + OpenAiModelListItem { + id: "gemini-2.5-pro".to_string(), + created: Some(1), + }, + ], + }, + Some("gemini"), + ); + let ids = models.into_iter().map(|model| model.id).collect::>(); + assert_eq!( + ids, + vec!["gemini-2.5-flash".to_string(), "gemini-2.5-pro".to_string()] + ); +} + #[test] fn anthropic_models_url_uses_anthropic_default_base_url() { assert_eq!( diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs index fce54edc40..138a650443 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs @@ -199,6 +199,27 @@ providers: ); } + #[test] + fn parse_nested_google_provider() { + // Persisted `active_provider: google` with `providers.google.model` — + // Goose's native Gemini provider. The bridge surfaces provider + model + // but must NOT parse or infer the GOOGLE_API_KEY secret (Goose keeps it + // in its own secret store, not config.yaml). + let yaml = r#" +active_provider: google +providers: + google: + model: gemini-2.5-pro +"#; + let cfg = parse_goose_config(yaml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("google")); + assert_eq!(cfg.model.as_deref(), Some("gemini-2.5-pro")); + assert!( + !cfg.extra.contains_key("GOOGLE_API_KEY"), + "the file bridge must never surface GOOGLE_API_KEY from config.yaml" + ); + } + #[test] fn non_databricks_provider_uses_provider_host_key() { let yaml = r#" diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 87ee6241ee..dfd1c760d7 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -242,6 +242,8 @@ impl AgentReadiness { /// provider-specific credentials are required: /// - `anthropic` → `ANTHROPIC_API_KEY` /// - `openai` → `OPENAI_COMPAT_API_KEY` +/// - `gemini` → `GEMINI_API_KEY` (buzz-agent's OpenAI-compatible provider) +/// - `google` → `GOOGLE_API_KEY` (goose's native Gemini provider) /// - `databricks` / `databricks_v2` → `DATABRICKS_HOST` (token optional — /// OAuth PKCE is the fallback) /// * **claude**: a successful `claude auth status` probe. @@ -329,6 +331,7 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { } Some("anthropic") => Some("ANTHROPIC_MODEL"), Some("openai") | Some("openai-compat") => Some("OPENAI_COMPAT_MODEL"), + Some("gemini") => Some("GEMINI_MODEL"), _ => None, }; let model_present = effective @@ -363,6 +366,12 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { key: "OPENAI_COMPAT_API_KEY".to_string(), }); } + Some("gemini") + if env_key_missing("GEMINI_API_KEY") => { + missing.push(Requirement::EnvKey { + key: "GEMINI_API_KEY".to_string(), + }); + } Some("databricks") | Some("databricks_v2") | Some("databricks-v2") // DATABRICKS_HOST is hard-required; DATABRICKS_TOKEN is optional // (OAuth PKCE is the normal path — see buzz-agent/src/config.rs:143). @@ -471,6 +480,15 @@ fn goose_requirements( key: "OPENAI_COMPAT_API_KEY".to_string(), }); } + // Goose's native Gemini provider. Unlike DATABRICKS_HOST, Goose keeps + // the Google API key in its own secret store — never in config.yaml — + // so GOOGLE_API_KEY is checked against Buzz's env layer only and is + // never silenced by the file config (the bridge does not parse it). + Some("google") if env_key_missing("GOOGLE_API_KEY") => { + missing.push(Requirement::EnvKey { + key: "GOOGLE_API_KEY".to_string(), + }); + } Some("databricks") | Some("databricks_v2") | Some("databricks-v2") if env_key_missing("DATABRICKS_HOST") && !file_key_present("DATABRICKS_HOST") => { @@ -487,1012 +505,8 @@ fn goose_requirements( // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use super::*; - use crate::managed_agents::discovery::known_acp_runtime_exact; - - /// Build a minimal `EffectiveAgentEnv` with the given env map and command. - fn make_env(command: &str, env: BTreeMap) -> EffectiveAgentEnv { - let runtime = known_acp_runtime_exact(command); - EffectiveAgentEnv { - env, - config_file_path: runtime.and_then(|r| r.config_file_path), - effective_command: command.to_string(), - } - } - - fn env_with(pairs: &[(&str, &str)]) -> BTreeMap { - pairs - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() - } - - // ── buzz-agent tests ────────────────────────────────────────────────── - - #[test] - fn buzz_agent_missing_provider_returns_not_ready_with_normalized_field() { - let env = make_env( - "buzz-agent", - env_with(&[("BUZZ_AGENT_MODEL", "claude-opus-4-5")]), - ); - let result = agent_readiness(&env); - assert!( - !result.is_ready(), - "missing BUZZ_AGENT_PROVIDER should be NotReady" - ); - let reqs = result.requirements(); - assert!( - reqs.contains(&Requirement::NormalizedField { - field: "provider".to_string() - }), - "requirements should include NormalizedField(provider); got {reqs:?}" - ); - } - - #[test] - fn buzz_agent_missing_model_returns_not_ready_with_normalized_field() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "anthropic"), - ("ANTHROPIC_API_KEY", "sk-test"), - ]), - ); - let result = agent_readiness(&env); - assert!(!result.is_ready()); - assert!(result - .requirements() - .contains(&Requirement::NormalizedField { - field: "model".to_string() - })); - } - - #[test] - fn buzz_agent_missing_anthropic_key_returns_not_ready_with_env_key() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "anthropic"), - ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), - ]), - ); - let result = agent_readiness(&env); - assert!(!result.is_ready()); - assert!(result.requirements().contains(&Requirement::EnvKey { - key: "ANTHROPIC_API_KEY".to_string() - })); - } - - #[test] - fn buzz_agent_missing_openai_key_returns_not_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openai"), - ("BUZZ_AGENT_MODEL", "gpt-4o"), - ]), - ); - let result = agent_readiness(&env); - assert!(!result.is_ready()); - assert!(result.requirements().contains(&Requirement::EnvKey { - key: "OPENAI_COMPAT_API_KEY".to_string() - })); - } - - #[test] - fn buzz_agent_anthropic_with_all_fields_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "anthropic"), - ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), - ("ANTHROPIC_API_KEY", "sk-test"), - ]), - ); - assert!(agent_readiness(&env).is_ready()); - } - - #[test] - fn buzz_agent_databricks_with_host_and_model_is_ready_without_token() { - // DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path. - // No token present, no OAuth cache present → still Ready because we - // cannot evaluate OAuth state from the env map alone. - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "databricks"), - ("BUZZ_AGENT_MODEL", "dbrx-instruct"), - ("DATABRICKS_HOST", "https://dbc.example.com"), - // NOTE: no DATABRICKS_TOKEN - ]), - ); - assert!( - agent_readiness(&env).is_ready(), - "Databricks with HOST+model but no TOKEN should still be Ready (OAuth path)" - ); - } - - #[test] - fn buzz_agent_databricks_missing_host_returns_not_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "databricks"), - ("BUZZ_AGENT_MODEL", "dbrx-instruct"), - // NOTE: no DATABRICKS_HOST - ]), - ); - let result = agent_readiness(&env); - assert!(!result.is_ready()); - assert!(result.requirements().contains(&Requirement::EnvKey { - key: "DATABRICKS_HOST".to_string() - })); - } - - #[test] - fn buzz_agent_databricks_v2_missing_host_returns_not_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "databricks_v2"), - ( - "BUZZ_AGENT_MODEL", - "databricks/meta-llama-4-maverick-17b-instruct", - ), - ]), - ); - let result = agent_readiness(&env); - assert!(!result.is_ready()); - assert!(result.requirements().contains(&Requirement::EnvKey { - key: "DATABRICKS_HOST".to_string() - })); - } - - // ── goose tests ─────────────────────────────────────────────────────── - - #[test] - fn goose_missing_provider_returns_not_ready() { - // Call goose_requirements directly with None file config so the test is - // deterministic — the `agent_readiness` path reads the real - // ~/.config/goose/config.yaml which may silence requirements on - // developer machines. - let env = make_env("goose", env_with(&[("GOOSE_MODEL", "claude-opus-4-5")])); - let reqs = goose_requirements(&env, None); - assert!( - !reqs.is_empty(), - "missing GOOSE_PROVIDER with no file config must produce requirements" - ); - assert!( - reqs.contains(&Requirement::NormalizedField { - field: "provider".to_string() - }), - "requirements must include NormalizedField(provider); got {reqs:?}" - ); - } - - #[test] - fn goose_with_provider_and_model_and_key_is_ready() { - let env = make_env( - "goose", - env_with(&[ - ("GOOSE_PROVIDER", "anthropic"), - ("GOOSE_MODEL", "claude-opus-4-5"), - ("ANTHROPIC_API_KEY", "sk-test"), - ]), - ); - assert!(agent_readiness(&env).is_ready()); - } - - // ── empty-string semantics ──────────────────────────────────────────── - // - // A key present with an empty value ("") must be treated as MISSING, to - // match the dialog's (envVars[key] ?? "").length === 0 emptiness check. - - #[test] - fn buzz_agent_empty_string_provider_is_not_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", ""), - ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), - ]), - ); - let result = agent_readiness(&env); - assert!( - !result.is_ready(), - "empty-string BUZZ_AGENT_PROVIDER must be treated as missing" - ); - assert!(result - .requirements() - .contains(&Requirement::NormalizedField { - field: "provider".to_string() - })); - } - - #[test] - fn buzz_agent_empty_string_model_is_not_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "anthropic"), - ("BUZZ_AGENT_MODEL", ""), - ("ANTHROPIC_API_KEY", "sk-test"), - ]), - ); - let result = agent_readiness(&env); - assert!( - !result.is_ready(), - "empty-string BUZZ_AGENT_MODEL must be treated as missing" - ); - assert!(result - .requirements() - .contains(&Requirement::NormalizedField { - field: "model".to_string() - })); - } - - #[test] - fn buzz_agent_empty_string_anthropic_key_is_not_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "anthropic"), - ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), - ("ANTHROPIC_API_KEY", ""), - ]), - ); - let result = agent_readiness(&env); - assert!( - !result.is_ready(), - "empty-string ANTHROPIC_API_KEY must be treated as missing" - ); - assert!(result.requirements().contains(&Requirement::EnvKey { - key: "ANTHROPIC_API_KEY".to_string() - })); - } - - #[test] - fn buzz_agent_empty_string_databricks_host_is_not_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "databricks"), - ("BUZZ_AGENT_MODEL", "dbrx-instruct"), - ("DATABRICKS_HOST", ""), - ]), - ); - let result = agent_readiness(&env); - assert!( - !result.is_ready(), - "empty-string DATABRICKS_HOST must be treated as missing" - ); - assert!(result.requirements().contains(&Requirement::EnvKey { - key: "DATABRICKS_HOST".to_string() - })); - } - - #[test] - fn goose_empty_string_provider_is_not_ready() { - // Call goose_requirements directly with None file config so the test is - // deterministic — the `agent_readiness` path reads the real - // ~/.config/goose/config.yaml which may silence requirements on - // developer machines. - let env = make_env( - "goose", - env_with(&[("GOOSE_PROVIDER", ""), ("GOOSE_MODEL", "claude-opus-4-5")]), - ); - let reqs = goose_requirements(&env, None); - assert!( - !reqs.is_empty(), - "empty-string GOOSE_PROVIDER must be treated as missing" - ); - assert!( - reqs.contains(&Requirement::NormalizedField { - field: "provider".to_string() - }), - "requirements must include NormalizedField(provider); got {reqs:?}" - ); - } - - #[test] - fn goose_empty_string_anthropic_key_is_not_ready() { - // Call goose_requirements directly with None file config so the test is - // deterministic — the `agent_readiness` path reads the real - // ~/.config/goose/config.yaml which may silence requirements on - // developer machines. - let env = make_env( - "goose", - env_with(&[ - ("GOOSE_PROVIDER", "anthropic"), - ("GOOSE_MODEL", "claude-opus-4-5"), - ("ANTHROPIC_API_KEY", ""), - ]), - ); - let reqs = goose_requirements(&env, None); - assert!( - !reqs.is_empty(), - "empty-string ANTHROPIC_API_KEY must be treated as missing (goose)" - ); - assert!( - reqs.contains(&Requirement::EnvKey { - key: "ANTHROPIC_API_KEY".to_string() - }), - "requirements must include ANTHROPIC_API_KEY; got {reqs:?}" - ); - } - - // ── codex tests ─────────────────────────────────────────────────────── - - #[test] - fn codex_not_ready_copy_does_not_mention_openai_api_key() { - // codex uses its own credential store via `codex login` (OAuth or API key). - // The nudge copy must NOT say "set OPENAI_API_KEY". - // Use a not-installed runtime so the requirement is always emitted - // regardless of whether codex is on the test machine's PATH. - let rt = make_cli_runtime(&["__buzz_nonexistent_adapter_xyz789__"], None); - let reqs = cli_login::requirements(&["codex", "login", "status"], "run `codex login`", &rt); - // Whether codex is installed or not, the copy (if any) must not mention OPENAI_API_KEY. - for req in &reqs { - if let Requirement::CliLogin { setup_copy, .. } = req { - assert!( - !setup_copy.contains("OPENAI_API_KEY"), - "codex nudge copy must not mention OPENAI_API_KEY; got: {setup_copy:?}" - ); - assert!( - setup_copy.contains("codex login"), - "codex nudge copy should mention `codex login`; got: {setup_copy:?}" - ); - } - } - } - - // ── cli_login_requirements: resolve_command integration ───────────── - - /// Construct a minimal `KnownAcpRuntime` stub for testing cli_login_requirements. - /// `commands` are the adapter binaries; `underlying_cli` is the CLI name. - fn make_cli_runtime( - commands: &'static [&'static str], - underlying_cli: Option<&'static str>, - ) -> KnownAcpRuntime { - KnownAcpRuntime { - id: "test-cli-runtime", - label: "Test CLI", - commands, - aliases: &[], - avatar_url: "", - mcp_command: None, - mcp_hooks: false, - underlying_cli, - cli_install_commands: &[], - cli_install_commands_windows: &[], - adapter_install_commands: &[], - install_instructions_url: "", - cli_install_hint: "", - adapter_install_hint: "", - skill_dir: None, - supports_acp_model_switching: false, - config_file_path: None, - config_file_format: None, - model_env_var: None, - provider_env_var: None, - provider_locked: false, - default_env: &[], - supports_acp_native_config: false, - thinking_env_var: None, - max_tokens_env_var: None, - context_limit_env_var: None, - required_normalized_fields: &[], - login_hint: None, - auth_probe_args: None, - } - } - - /// Returns the absolute path of the currently-running test binary as a - /// `&'static str`. Host-portable stand-in for a "present" binary: - /// the path is absolute so `find_command` resolves it via `path.exists()` - /// rather than searching `PATH`, and the file always exists on the host. - /// - /// The tiny allocation is intentionally leaked — this runs at most once per - /// test process and the process exits immediately after tests complete. - fn present_binary_str() -> &'static str { - let path = std::env::current_exe().expect("current_exe must be available in tests"); - Box::leak(path.to_string_lossy().into_owned().into_boxed_str()) - } - - /// Leak a runtime slice of `'static` strs for use in `make_cli_runtime`. - fn static_commands(commands: Vec<&'static str>) -> &'static [&'static str] { - Box::leak(commands.into_boxed_slice()) - } - - #[test] - fn cli_login_requirements_missing_binary_is_not_ready() { - // Both adapter and underlying CLI are nonexistent → NotInstalled state - // → must return a CliLogin requirement with availability=NotInstalled. - let rt = make_cli_runtime( - &["__buzz_nonexistent_adapter_abc123__"], - Some("__buzz_nonexistent_cli_abc123__"), - ); - let reqs = cli_login::requirements( - &["__buzz_nonexistent_binary_abc123__", "status"], - "install the tool first", - &rt, - ); - assert!( - !reqs.is_empty(), - "missing binary must produce a CliLogin requirement (NotReady)" - ); - assert!( - matches!(reqs[0], Requirement::CliLogin { .. }), - "requirement must be CliLogin; got {:?}", - reqs[0] - ); - if let Requirement::CliLogin { - ref availability, .. - } = reqs[0] - { - assert_eq!( - *availability, - crate::managed_agents::AcpAvailabilityStatus::NotInstalled, - "both missing → NotInstalled" - ); - } - } - - #[test] - fn cli_login_requirements_adapter_missing_emits_adapter_missing() { - // Underlying CLI present (use the running test binary as a portable - // stand-in — it's always present and resolves via absolute path), - // adapter absent. - // → AdapterMissing state → no probe run → CliLogin{AdapterMissing}. - let exe = present_binary_str(); - let rt = make_cli_runtime(&["__buzz_nonexistent_adapter_xyz789__"], Some(exe)); - let reqs = cli_login::requirements(&[exe, "--list"], "install the adapter", &rt); - assert!( - !reqs.is_empty(), - "adapter missing must produce a CliLogin requirement" - ); - if let Requirement::CliLogin { - ref availability, .. - } = reqs[0] - { - assert_eq!( - *availability, - crate::managed_agents::AcpAvailabilityStatus::AdapterMissing, - "adapter absent, CLI present → AdapterMissing" - ); - } - } - - #[test] - fn cli_login_requirements_cli_missing_emits_cli_missing() { - // Adapter present (use the running test binary as a portable stand-in), - // underlying CLI absent. - // → CliMissing state → no probe run → CliLogin{CliMissing}. - let exe = present_binary_str(); - let rt = make_cli_runtime( - static_commands(vec![exe]), // adapter found via absolute path - Some("__buzz_nonexistent_cli_abc123__"), // underlying CLI missing - ); - let reqs = cli_login::requirements(&[exe, "--list"], "install the CLI", &rt); - assert!( - !reqs.is_empty(), - "CLI missing must produce a CliLogin requirement" - ); - if let Requirement::CliLogin { - ref availability, .. - } = reqs[0] - { - assert_eq!( - *availability, - crate::managed_agents::AcpAvailabilityStatus::CliMissing, - "adapter present, CLI absent → CliMissing" - ); - } - } - - #[test] - fn cli_login_requirements_resolvable_binary_runs_probe_at_resolved_path() { - // Both adapter and CLI present (use the running test binary as a - // portable stand-in — always present, resolves via absolute path), - // probe exits 0 (run with `--list` which lists tests and exits 0). - // → logged_in = true → requirements is empty (Ready). - let exe = present_binary_str(); - let rt = make_cli_runtime(static_commands(vec![exe]), Some(exe)); - let reqs = cli_login::requirements( - &[exe, "--list"], - "this should not show (probe exits 0)", - &rt, - ); - assert!( - reqs.is_empty(), - "expected Ready (no requirements) when probe binary resolves and exits 0; \ - got {:?}", - reqs - ); - } - - #[test] - fn cli_login_requirements_logged_out_emits_available() { - // Both adapter and CLI present, but probe exits non-zero (logged out). - // Use the test binary with an unrecognized argument as the probe — - // libtest exits non-zero for unknown flags on all platforms. - // → CliLogin{Available} (tooling installed, needs login). - let exe = present_binary_str(); - let rt = make_cli_runtime(static_commands(vec![exe]), Some(exe)); - let reqs = - cli_login::requirements(&[exe, "--buzz-probe-fail-xyz"], "run `tool login`", &rt); - assert!( - !reqs.is_empty(), - "non-zero probe must produce a CliLogin requirement (logged out)" - ); - if let Requirement::CliLogin { - ref availability, .. - } = reqs[0] - { - assert_eq!( - *availability, - crate::managed_agents::AcpAvailabilityStatus::Available, - "tooling installed, probe fails → Available (logged-out)" - ); - } - } - - // ── codex readiness version gate ─────────────────────────────────────── - - /// Build a minimal `KnownAcpRuntime` for testing the codex version gate. - /// `adapter_commands` are the exact strings passed to `find_command` — use - /// `&["codex-acp"]` when the binary is on PATH, or `&[]` - /// when resolving via absolute path. `underlying_cli` is a portable - /// stand-in so the adapter is not misclassified as `CliMissing`. - fn make_codex_runtime( - adapter_commands: &'static [&'static str], - underlying_cli: Option<&'static str>, - ) -> KnownAcpRuntime { - KnownAcpRuntime { - id: "codex", - label: "Codex", - commands: adapter_commands, - aliases: &[], - avatar_url: "", - mcp_command: None, - mcp_hooks: false, - underlying_cli, - cli_install_commands: &[], - cli_install_commands_windows: &[], - adapter_install_commands: &[], - install_instructions_url: "", - cli_install_hint: "", - adapter_install_hint: "", - skill_dir: None, - supports_acp_model_switching: false, - config_file_path: None, - config_file_format: None, - model_env_var: None, - provider_env_var: None, - provider_locked: false, - default_env: &[], - supports_acp_native_config: false, - thinking_env_var: None, - max_tokens_env_var: None, - context_limit_env_var: None, - required_normalized_fields: &[], - login_hint: None, - auth_probe_args: None, - } - } - - /// Build a temp dir containing a `codex-acp` script with the given body, - /// prepend it to PATH, and clear the resolve cache. Returns the temp dir - /// and the original PATH string for restoration. - #[cfg(unix)] - fn setup_temp_codex_acp(script_body: &str) -> (tempfile::TempDir, String) { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().expect("create temp dir"); - let bin = dir.path().join("codex-acp"); - std::fs::write(&bin, script_body).expect("write script"); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) - .expect("chmod script"); - - let original_path = std::env::var("PATH").unwrap_or_default(); - let new_path = format!("{}:{}", dir.path().display(), original_path); - std::env::set_var("PATH", &new_path); - crate::managed_agents::clear_resolve_cache(); - - (dir, original_path) - } - - #[cfg(unix)] - fn leaked_adapter_commands(bin: &std::path::Path) -> &'static [&'static str] { - let command = Box::leak(bin.display().to_string().into_boxed_str()); - Box::leak(vec![command as &'static str].into_boxed_slice()) - } - - /// Restore PATH and clear the resolve cache after a PATH-mutating test. - #[cfg(unix)] - fn restore_path(original: &str) { - std::env::set_var("PATH", original); - crate::managed_agents::clear_resolve_cache(); - } - - /// Codex readiness: outdated adapter (exits non-zero) → AdapterOutdated, - /// login probe skipped. - #[cfg(unix)] - #[test] - fn cli_login_requirements_codex_outdated_adapter_emits_adapter_outdated() { - let _guard = crate::managed_agents::lock_path_mutex(); - - let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\nexit 1\n"); - let exe = present_binary_str(); - // Use the fixture's absolute adapter path here. Bare `codex-acp` - // intentionally prefers Buzz's managed npm shim when it exists, which - // would make this version-gate regression test depend on machine state. - let rt = make_codex_runtime( - leaked_adapter_commands(&dir.path().join("codex-acp")), - Some(exe), - ); - let reqs = cli_login::requirements( - &[exe, "--buzz-probe-must-not-run-xyz"], - "run `codex login`", - &rt, - ); - - restore_path(&orig); - drop(dir); - - assert!( - !reqs.is_empty(), - "outdated codex adapter must produce a requirement; got {reqs:?}" - ); - if let Requirement::CliLogin { - ref availability, .. - } = reqs[0] - { - assert_eq!( - *availability, - crate::managed_agents::AcpAvailabilityStatus::AdapterOutdated, - "0.x codex adapter must yield AdapterOutdated; got {availability:?}" - ); - } else { - panic!("expected CliLogin requirement; got {:?}", reqs[0]); - } - } - - /// Codex readiness: adapter exits 0 but output is not a parseable version - /// → AdapterOutdated (garbage output treated as outdated, same as non-zero). - #[cfg(unix)] - #[test] - fn cli_login_requirements_codex_garbage_version_output_emits_adapter_outdated() { - let _guard = crate::managed_agents::lock_path_mutex(); - - let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\necho 'not a version string'\nexit 0\n"); - let exe = present_binary_str(); - let rt = make_codex_runtime( - leaked_adapter_commands(&dir.path().join("codex-acp")), - Some(exe), - ); - let reqs = cli_login::requirements( - &[exe, "--buzz-probe-must-not-run-xyz"], - "run `codex login`", - &rt, - ); - - restore_path(&orig); - drop(dir); - - assert!( - !reqs.is_empty(), - "garbage version output must produce a requirement; got {reqs:?}" - ); - if let Requirement::CliLogin { - ref availability, .. - } = reqs[0] - { - assert_eq!( - *availability, - crate::managed_agents::AcpAvailabilityStatus::AdapterOutdated, - "unparseable version output must yield AdapterOutdated; got {availability:?}" - ); - } else { - panic!("expected CliLogin requirement; got {:?}", reqs[0]); - } - } - - // ── custom/unknown command ───────────────────────────────────────────── - - #[test] - fn unknown_command_is_always_ready() { - let env = make_env("my-custom-harness", BTreeMap::new()); - assert!( - agent_readiness(&env).is_ready(), - "unknown/custom command should always be Ready (no requirements)" - ); - } - - // ── AgentReadiness helpers ───────────────────────────────────────────── - - #[test] - fn agent_readiness_ready_has_empty_requirements() { - assert!(AgentReadiness::Ready.requirements().is_empty()); - } - - #[test] - fn agent_readiness_not_ready_exposes_requirements() { - let r = AgentReadiness::NotReady { - requirements: vec![Requirement::EnvKey { - key: "FOO".to_string(), - }], - }; - assert!(!r.is_ready()); - assert_eq!(r.requirements().len(), 1); - } - - // ── Requirement serialization ───────────────────────────────────────── - - #[test] - fn requirement_serializes_with_surface_tag() { - let r = Requirement::NormalizedField { - field: "provider".to_string(), - }; - let json = serde_json::to_value(&r).unwrap(); - assert_eq!(json["surface"], "normalized_field"); - assert_eq!(json["field"], "provider"); - } - - #[test] - fn git_bash_requirement_serializes_correctly() { - let json = serde_json::to_value(Requirement::GitBash).unwrap(); - assert_eq!(json, serde_json::json!({ "surface": "git_bash" })); - } - - #[test] - fn env_key_requirement_serializes_correctly() { - let r = Requirement::EnvKey { - key: "ANTHROPIC_API_KEY".to_string(), - }; - let json = serde_json::to_value(&r).unwrap(); - assert_eq!(json["surface"], "env_key"); - assert_eq!(json["key"], "ANTHROPIC_API_KEY"); - } - - #[test] - fn cli_login_requirement_serializes_correctly() { - let r = Requirement::CliLogin { - probe_args: vec![ - "codex".to_string(), - "login".to_string(), - "status".to_string(), - ], - setup_copy: "run `codex login`".to_string(), - availability: crate::managed_agents::AcpAvailabilityStatus::Available, - }; - let json = serde_json::to_value(&r).unwrap(); - assert_eq!(json["surface"], "cli_login"); - assert!(json["probe_args"].is_array()); - assert!(json["setup_copy"].as_str().unwrap().contains("codex login")); - } - - // ── resolve_effective_agent_env ───────────────────────────────────────── - - #[test] - fn resolve_effective_agent_env_user_env_wins_over_structured_fields() { - // A record whose env_vars explicitly set provider/model must win over - // any baked defaults. In OSS test builds the baked map is empty, so - // this test validates the user-env layer is present in the output. - let mut env_vars = BTreeMap::new(); - env_vars.insert("BUZZ_AGENT_PROVIDER".to_string(), "anthropic".to_string()); - env_vars.insert( - "BUZZ_AGENT_MODEL".to_string(), - "claude-opus-4-5".to_string(), - ); - - // Minimal record: only the fields resolve_effective_agent_env reads. - let record = crate::managed_agents::types::ManagedAgentRecord { - pubkey: "test-pubkey".to_string(), - name: "test-agent".to_string(), - persona_id: None, - private_key_nsec: String::new(), - auth_tag: None, - relay_url: String::new(), - avatar_url: None, - acp_command: "buzz-acp".to_string(), - agent_command: "buzz-agent".to_string(), - agent_command_override: None, - agent_args: vec![], - mcp_command: String::new(), - turn_timeout_seconds: 320, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - provider: None, - persona_source_version: None, - env_vars, - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: Default::default(), - backend_agent_id: None, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - created_at: String::new(), - updated_at: String::new(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to: Default::default(), - respond_to_allowlist: vec![], - display_name: None, - slug: None, - runtime: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - source_team: None, - source_team_persona_slug: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - }; - - let runtime = known_acp_runtime_exact("buzz-agent"); - let effective = resolve_effective_agent_env(&record, &[], runtime, &Default::default()); - - // User env_vars must be present in the output (last-write-wins). - assert_eq!( - effective.env.get("BUZZ_AGENT_PROVIDER").map(String::as_str), - Some("anthropic") - ); - assert_eq!( - effective.env.get("BUZZ_AGENT_MODEL").map(String::as_str), - Some("claude-opus-4-5") - ); - } - - // ── provider-specific model fallback tests ──────────────────────────── - - #[test] - fn buzz_agent_databricks_v2_with_databricks_model_but_no_buzz_agent_model_is_ready() { - // The baked buzz-releases env sets DATABRICKS_MODEL but not BUZZ_AGENT_MODEL. - // An agent with only DATABRICKS_MODEL must pass the readiness gate. - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "databricks_v2"), - ("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"), - ("DATABRICKS_HOST", "https://dbc.example.com"), - ]), - ); - assert!( - agent_readiness(&env).is_ready(), - "DATABRICKS_MODEL must satisfy the model requirement for databricks_v2" - ); - } - - #[test] - fn buzz_agent_databricks_v2_hyphen_alias_with_databricks_model_is_ready() { - // buzz-agent accepts both "databricks_v2" and "databricks-v2". The - // readiness gate must recognize the hyphen alias and accept DATABRICKS_MODEL. - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "databricks-v2"), - ("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"), - ("DATABRICKS_HOST", "https://dbc.example.com"), - ]), - ); - assert!( - agent_readiness(&env).is_ready(), - "databricks-v2 alias with DATABRICKS_MODEL must be Ready" - ); - } - - #[test] - fn buzz_agent_databricks_hyphen_alias_missing_host_returns_not_ready() { - // The hyphen alias "databricks-v2" requires DATABRICKS_HOST just like - // the underscore variants. Without it the agent cannot reach the endpoint. - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "databricks-v2"), - ("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"), - // DATABRICKS_HOST intentionally absent - ]), - ); - let result = agent_readiness(&env); - assert!( - !result.is_ready(), - "databricks-v2 without DATABRICKS_HOST must be NotReady" - ); - let reqs = result.requirements(); - assert!( - reqs.iter() - .any(|r| matches!(r, Requirement::EnvKey { key } if key == "DATABRICKS_HOST")), - "missing requirements must include DATABRICKS_HOST; got {reqs:?}" - ); - } - - #[test] - fn buzz_agent_databricks_v1_with_databricks_model_but_no_buzz_agent_model_is_ready() { - // V1 (Model Serving) also resolves DATABRICKS_MODEL — same fallback applies. - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "databricks"), - ("DATABRICKS_MODEL", "dbrx-instruct"), - ("DATABRICKS_HOST", "https://dbc.example.com"), - ]), - ); - assert!( - agent_readiness(&env).is_ready(), - "DATABRICKS_MODEL must satisfy the model requirement for databricks (V1)" - ); - } - - #[test] - fn buzz_agent_anthropic_with_anthropic_model_but_no_buzz_agent_model_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "anthropic"), - ("ANTHROPIC_MODEL", "claude-opus-4-5"), - ("ANTHROPIC_API_KEY", "sk-test"), - ]), - ); - assert!( - agent_readiness(&env).is_ready(), - "ANTHROPIC_MODEL must satisfy the model requirement for anthropic" - ); - } - - #[test] - fn buzz_agent_openai_with_openai_compat_model_but_no_buzz_agent_model_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openai"), - ("OPENAI_COMPAT_MODEL", "gpt-4o"), - ("OPENAI_COMPAT_API_KEY", "sk-test"), - ]), - ); - assert!( - agent_readiness(&env).is_ready(), - "OPENAI_COMPAT_MODEL must satisfy the model requirement for openai" - ); - } - - #[test] - fn buzz_agent_empty_provider_model_fallback_key_is_not_ready() { - // An empty DATABRICKS_MODEL with no BUZZ_AGENT_MODEL must still be NotReady. - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "databricks_v2"), - ("DATABRICKS_MODEL", ""), - ("DATABRICKS_HOST", "https://dbc.example.com"), - ]), - ); - let result = agent_readiness(&env); - assert!( - !result.is_ready(), - "empty DATABRICKS_MODEL with no BUZZ_AGENT_MODEL must be NotReady" - ); - assert!(result - .requirements() - .contains(&Requirement::NormalizedField { - field: "model".to_string() - })); - } -} +#[path = "readiness_tests.rs"] +mod tests; // ── goose file-config–aware requirement tests ───────────────────────────── // @@ -1572,6 +586,74 @@ mod goose_file_config_tests { ); } + #[test] + fn goose_google_provider_from_file_config_still_requires_env_google_api_key() { + // Persisted `active_provider: google` with `providers.google.model` in + // config.yaml surfaces via the config bridge as provider=google + + // model=gemini-2.5-pro. Provider and model are silenced by the file, + // but Goose keeps the API key in its own secret store (never in + // config.yaml), so GOOGLE_API_KEY must still be required from Buzz's + // env layer — the file config cannot silence it. + let cfg = RuntimeFileConfig { + provider: Some("google".to_string()), + model: Some("gemini-2.5-pro".to_string()), + extra: BTreeMap::new(), + ..Default::default() + }; + let env = empty_env(); + let reqs = goose_requirements(&env, Some(&cfg)); + assert_eq!( + reqs, + vec![Requirement::EnvKey { + key: "GOOGLE_API_KEY".to_string(), + }], + "file-config google provider must still require GOOGLE_API_KEY from env; got {reqs:?}" + ); + } + + #[test] + fn goose_google_provider_ready_when_env_supplies_google_api_key() { + // File config provides provider+model; env supplies GOOGLE_API_KEY → + // all requirements satisfied. + let cfg = RuntimeFileConfig { + provider: Some("google".to_string()), + model: Some("gemini-2.5-pro".to_string()), + extra: BTreeMap::new(), + ..Default::default() + }; + let env = env_with(&[("GOOGLE_API_KEY", "secret")]); + let reqs = goose_requirements(&env, Some(&cfg)); + assert!( + reqs.is_empty(), + "goose+google with env GOOGLE_API_KEY and file provider/model must be ready; got {reqs:?}" + ); + } + + #[test] + fn goose_env_provider_overrides_file_provider_for_credentials() { + // Precedence: an env GOOSE_PROVIDER=google wins over a file + // active_provider (here anthropic), so the required credential is + // GOOGLE_API_KEY — not ANTHROPIC_API_KEY from the file's provider. + let cfg = RuntimeFileConfig { + provider: Some("anthropic".to_string()), + model: Some("claude-opus-4-5".to_string()), + extra: BTreeMap::new(), + ..Default::default() + }; + let env = env_with(&[ + ("GOOSE_PROVIDER", "google"), + ("GOOSE_MODEL", "gemini-2.5-pro"), + ]); + let reqs = goose_requirements(&env, Some(&cfg)); + assert_eq!( + reqs, + vec![Requirement::EnvKey { + key: "GOOGLE_API_KEY".to_string(), + }], + "env GOOSE_PROVIDER=google must override file provider and require GOOGLE_API_KEY; got {reqs:?}" + ); + } + #[test] fn goose_file_config_silences_provider_and_model_but_not_anthropic_key() { // File has provider=anthropic and model, but ANTHROPIC_API_KEY is not diff --git a/desktop/src-tauri/src/managed_agents/readiness_tests.rs b/desktop/src-tauri/src/managed_agents/readiness_tests.rs new file mode 100644 index 0000000000..fce553b617 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness_tests.rs @@ -0,0 +1,1124 @@ +use std::collections::BTreeMap; + +use super::*; +use crate::managed_agents::discovery::known_acp_runtime_exact; + +/// Build a minimal `EffectiveAgentEnv` with the given env map and command. +fn make_env(command: &str, env: BTreeMap) -> EffectiveAgentEnv { + let runtime = known_acp_runtime_exact(command); + EffectiveAgentEnv { + env, + config_file_path: runtime.and_then(|r| r.config_file_path), + effective_command: command.to_string(), + } +} + +fn env_with(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +// ── buzz-agent tests ────────────────────────────────────────────────── + +#[test] +fn buzz_agent_missing_provider_returns_not_ready_with_normalized_field() { + let env = make_env( + "buzz-agent", + env_with(&[("BUZZ_AGENT_MODEL", "claude-opus-4-5")]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "missing BUZZ_AGENT_PROVIDER should be NotReady" + ); + let reqs = result.requirements(); + assert!( + reqs.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "requirements should include NormalizedField(provider); got {reqs:?}" + ); +} + +#[test] +fn buzz_agent_missing_model_returns_not_ready_with_normalized_field() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result + .requirements() + .contains(&Requirement::NormalizedField { + field: "model".to_string() + })); +} + +#[test] +fn buzz_agent_missing_anthropic_key_returns_not_ready_with_env_key() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + })); +} + +#[test] +fn buzz_agent_missing_openai_key_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openai"), + ("BUZZ_AGENT_MODEL", "gpt-4o"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "OPENAI_COMPAT_API_KEY".to_string() + })); +} + +#[test] +fn buzz_agent_missing_gemini_key_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "gemini"), + ("BUZZ_AGENT_MODEL", "gemini-2.5-flash"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "GEMINI_API_KEY".to_string() + })); +} + +#[test] +fn buzz_agent_gemini_with_all_fields_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "gemini"), + ("BUZZ_AGENT_MODEL", "gemini-2.5-flash"), + ("GEMINI_API_KEY", "AIza-test"), + ]), + ); + assert!(agent_readiness(&env).is_ready()); +} + +#[test] +fn buzz_agent_gemini_model_fallback_env_key_is_ready() { + // GEMINI_MODEL (not BUZZ_AGENT_MODEL) should satisfy the model requirement, + // mirroring the provider-specific fallback for the other providers. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "gemini"), + ("GEMINI_MODEL", "gemini-2.5-pro"), + ("GEMINI_API_KEY", "AIza-test"), + ]), + ); + assert!(agent_readiness(&env).is_ready()); +} + +#[test] +fn buzz_agent_anthropic_with_all_fields_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + assert!(agent_readiness(&env).is_ready()); +} + +#[test] +fn buzz_agent_databricks_with_host_and_model_is_ready_without_token() { + // DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path. + // No token present, no OAuth cache present → still Ready because we + // cannot evaluate OAuth state from the env map alone. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks"), + ("BUZZ_AGENT_MODEL", "dbrx-instruct"), + ("DATABRICKS_HOST", "https://dbc.example.com"), + // NOTE: no DATABRICKS_TOKEN + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "Databricks with HOST+model but no TOKEN should still be Ready (OAuth path)" + ); +} + +#[test] +fn buzz_agent_databricks_missing_host_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks"), + ("BUZZ_AGENT_MODEL", "dbrx-instruct"), + // NOTE: no DATABRICKS_HOST + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + })); +} + +#[test] +fn buzz_agent_databricks_v2_missing_host_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks_v2"), + ( + "BUZZ_AGENT_MODEL", + "databricks/meta-llama-4-maverick-17b-instruct", + ), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + })); +} + +// ── goose tests ─────────────────────────────────────────────────────── + +#[test] +fn goose_missing_provider_returns_not_ready() { + // Call goose_requirements directly with None file config so the test is + // deterministic — the `agent_readiness` path reads the real + // ~/.config/goose/config.yaml which may silence requirements on + // developer machines. + let env = make_env("goose", env_with(&[("GOOSE_MODEL", "claude-opus-4-5")])); + let reqs = goose_requirements(&env, None); + assert!( + !reqs.is_empty(), + "missing GOOSE_PROVIDER with no file config must produce requirements" + ); + assert!( + reqs.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "requirements must include NormalizedField(provider); got {reqs:?}" + ); +} + +#[test] +fn goose_with_provider_and_model_and_key_is_ready() { + let env = make_env( + "goose", + env_with(&[ + ("GOOSE_PROVIDER", "anthropic"), + ("GOOSE_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + assert!(agent_readiness(&env).is_ready()); +} + +// ── empty-string semantics ──────────────────────────────────────────── +// +// A key present with an empty value ("") must be treated as MISSING, to +// match the dialog's (envVars[key] ?? "").length === 0 emptiness check. + +#[test] +fn buzz_agent_empty_string_provider_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", ""), + ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string BUZZ_AGENT_PROVIDER must be treated as missing" + ); + assert!(result + .requirements() + .contains(&Requirement::NormalizedField { + field: "provider".to_string() + })); +} + +#[test] +fn buzz_agent_empty_string_model_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("BUZZ_AGENT_MODEL", ""), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string BUZZ_AGENT_MODEL must be treated as missing" + ); + assert!(result + .requirements() + .contains(&Requirement::NormalizedField { + field: "model".to_string() + })); +} + +#[test] +fn buzz_agent_empty_string_anthropic_key_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", ""), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string ANTHROPIC_API_KEY must be treated as missing" + ); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + })); +} + +#[test] +fn buzz_agent_empty_string_databricks_host_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks"), + ("BUZZ_AGENT_MODEL", "dbrx-instruct"), + ("DATABRICKS_HOST", ""), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string DATABRICKS_HOST must be treated as missing" + ); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + })); +} + +#[test] +fn goose_empty_string_provider_is_not_ready() { + // Call goose_requirements directly with None file config so the test is + // deterministic — the `agent_readiness` path reads the real + // ~/.config/goose/config.yaml which may silence requirements on + // developer machines. + let env = make_env( + "goose", + env_with(&[("GOOSE_PROVIDER", ""), ("GOOSE_MODEL", "claude-opus-4-5")]), + ); + let reqs = goose_requirements(&env, None); + assert!( + !reqs.is_empty(), + "empty-string GOOSE_PROVIDER must be treated as missing" + ); + assert!( + reqs.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "requirements must include NormalizedField(provider); got {reqs:?}" + ); +} + +#[test] +fn goose_empty_string_anthropic_key_is_not_ready() { + // Call goose_requirements directly with None file config so the test is + // deterministic — the `agent_readiness` path reads the real + // ~/.config/goose/config.yaml which may silence requirements on + // developer machines. + let env = make_env( + "goose", + env_with(&[ + ("GOOSE_PROVIDER", "anthropic"), + ("GOOSE_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", ""), + ]), + ); + let reqs = goose_requirements(&env, None); + assert!( + !reqs.is_empty(), + "empty-string ANTHROPIC_API_KEY must be treated as missing (goose)" + ); + assert!( + reqs.contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + }), + "requirements must include ANTHROPIC_API_KEY; got {reqs:?}" + ); +} + +// ── goose google (Gemini) provider tests ────────────────────────────── + +#[test] +fn goose_google_provider_missing_key_returns_not_ready() { + // Goose's native Gemini provider (`google`) requires GOOGLE_API_KEY. With + // provider + model set but no key, readiness must surface exactly that key. + let env = make_env( + "goose", + env_with(&[ + ("GOOSE_PROVIDER", "google"), + ("GOOSE_MODEL", "gemini-2.5-pro"), + ]), + ); + let reqs = goose_requirements(&env, None); + assert_eq!( + reqs, + vec![Requirement::EnvKey { + key: "GOOGLE_API_KEY".to_string(), + }], + "goose+google without a key must require GOOGLE_API_KEY; got {reqs:?}" + ); +} + +#[test] +fn goose_google_provider_with_all_fields_is_ready() { + let env = make_env( + "goose", + env_with(&[ + ("GOOSE_PROVIDER", "google"), + ("GOOSE_MODEL", "gemini-2.5-pro"), + ("GOOGLE_API_KEY", "secret"), + ]), + ); + let reqs = goose_requirements(&env, None); + assert!( + reqs.is_empty(), + "goose+google with GOOGLE_API_KEY, provider and model must be ready; got {reqs:?}" + ); +} + +#[test] +fn goose_anthropic_provider_remains_ready() { + // Regression: adding the google arm must not affect other goose providers. + let env = make_env( + "goose", + env_with(&[ + ("GOOSE_PROVIDER", "anthropic"), + ("GOOSE_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", "secret"), + ]), + ); + let reqs = goose_requirements(&env, None); + assert!( + reqs.is_empty(), + "goose+anthropic must be ready; got {reqs:?}" + ); +} + +#[test] +fn buzz_agent_gemini_provider_remains_supported() { + // Gemini IS a first-class buzz-agent provider (OpenAI-compatible, + // GEMINI_API_KEY) — distinct from goose's `google` provider. buzz-agent + // readiness must remain unchanged. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "gemini"), + ("BUZZ_AGENT_MODEL", "models/gemini-3.5-flash"), + ("GEMINI_API_KEY", "secret"), + ]), + ); + let reqs = buzz_agent_requirements(&env); + assert!( + reqs.is_empty(), + "buzz-agent+gemini with key+model must be ready; got {reqs:?}" + ); +} + +// ── codex tests ─────────────────────────────────────────────────────── + +#[test] +fn codex_not_ready_copy_does_not_mention_openai_api_key() { + // codex uses its own credential store via `codex login` (OAuth or API key). + // The nudge copy must NOT say "set OPENAI_API_KEY". + // Use a not-installed runtime so the requirement is always emitted + // regardless of whether codex is on the test machine's PATH. + let rt = make_cli_runtime(&["__buzz_nonexistent_adapter_xyz789__"], None); + let reqs = cli_login::requirements(&["codex", "login", "status"], "run `codex login`", &rt); + // Whether codex is installed or not, the copy (if any) must not mention OPENAI_API_KEY. + for req in &reqs { + if let Requirement::CliLogin { setup_copy, .. } = req { + assert!( + !setup_copy.contains("OPENAI_API_KEY"), + "codex nudge copy must not mention OPENAI_API_KEY; got: {setup_copy:?}" + ); + assert!( + setup_copy.contains("codex login"), + "codex nudge copy should mention `codex login`; got: {setup_copy:?}" + ); + } + } +} + +// ── cli_login_requirements: resolve_command integration ───────────── + +/// Construct a minimal `KnownAcpRuntime` stub for testing cli_login_requirements. +/// `commands` are the adapter binaries; `underlying_cli` is the CLI name. +fn make_cli_runtime( + commands: &'static [&'static str], + underlying_cli: Option<&'static str>, +) -> KnownAcpRuntime { + KnownAcpRuntime { + id: "test-cli-runtime", + label: "Test CLI", + commands, + aliases: &[], + avatar_url: "", + mcp_command: None, + mcp_hooks: false, + underlying_cli, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + install_instructions_url: "", + cli_install_hint: "", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: false, + config_file_path: None, + config_file_format: None, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + default_env: &[], + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: None, + auth_probe_args: None, + } +} + +/// Returns the absolute path of the currently-running test binary as a +/// `&'static str`. Host-portable stand-in for a "present" binary: +/// the path is absolute so `find_command` resolves it via `path.exists()` +/// rather than searching `PATH`, and the file always exists on the host. +/// +/// The tiny allocation is intentionally leaked — this runs at most once per +/// test process and the process exits immediately after tests complete. +fn present_binary_str() -> &'static str { + let path = std::env::current_exe().expect("current_exe must be available in tests"); + Box::leak(path.to_string_lossy().into_owned().into_boxed_str()) +} + +/// Leak a runtime slice of `'static` strs for use in `make_cli_runtime`. +fn static_commands(commands: Vec<&'static str>) -> &'static [&'static str] { + Box::leak(commands.into_boxed_slice()) +} + +#[test] +fn cli_login_requirements_missing_binary_is_not_ready() { + // Both adapter and underlying CLI are nonexistent → NotInstalled state + // → must return a CliLogin requirement with availability=NotInstalled. + let rt = make_cli_runtime( + &["__buzz_nonexistent_adapter_abc123__"], + Some("__buzz_nonexistent_cli_abc123__"), + ); + let reqs = cli_login::requirements( + &["__buzz_nonexistent_binary_abc123__", "status"], + "install the tool first", + &rt, + ); + assert!( + !reqs.is_empty(), + "missing binary must produce a CliLogin requirement (NotReady)" + ); + assert!( + matches!(reqs[0], Requirement::CliLogin { .. }), + "requirement must be CliLogin; got {:?}", + reqs[0] + ); + if let Requirement::CliLogin { + ref availability, .. + } = reqs[0] + { + assert_eq!( + *availability, + crate::managed_agents::AcpAvailabilityStatus::NotInstalled, + "both missing → NotInstalled" + ); + } +} + +#[test] +fn cli_login_requirements_adapter_missing_emits_adapter_missing() { + // Underlying CLI present (use the running test binary as a portable + // stand-in — it's always present and resolves via absolute path), + // adapter absent. + // → AdapterMissing state → no probe run → CliLogin{AdapterMissing}. + let exe = present_binary_str(); + let rt = make_cli_runtime(&["__buzz_nonexistent_adapter_xyz789__"], Some(exe)); + let reqs = cli_login::requirements(&[exe, "--list"], "install the adapter", &rt); + assert!( + !reqs.is_empty(), + "adapter missing must produce a CliLogin requirement" + ); + if let Requirement::CliLogin { + ref availability, .. + } = reqs[0] + { + assert_eq!( + *availability, + crate::managed_agents::AcpAvailabilityStatus::AdapterMissing, + "adapter absent, CLI present → AdapterMissing" + ); + } +} + +#[test] +fn cli_login_requirements_cli_missing_emits_cli_missing() { + // Adapter present (use the running test binary as a portable stand-in), + // underlying CLI absent. + // → CliMissing state → no probe run → CliLogin{CliMissing}. + let exe = present_binary_str(); + let rt = make_cli_runtime( + static_commands(vec![exe]), // adapter found via absolute path + Some("__buzz_nonexistent_cli_abc123__"), // underlying CLI missing + ); + let reqs = cli_login::requirements(&[exe, "--list"], "install the CLI", &rt); + assert!( + !reqs.is_empty(), + "CLI missing must produce a CliLogin requirement" + ); + if let Requirement::CliLogin { + ref availability, .. + } = reqs[0] + { + assert_eq!( + *availability, + crate::managed_agents::AcpAvailabilityStatus::CliMissing, + "adapter present, CLI absent → CliMissing" + ); + } +} + +#[test] +fn cli_login_requirements_resolvable_binary_runs_probe_at_resolved_path() { + // Both adapter and CLI present (use the running test binary as a + // portable stand-in — always present, resolves via absolute path), + // probe exits 0 (run with `--list` which lists tests and exits 0). + // → logged_in = true → requirements is empty (Ready). + let exe = present_binary_str(); + let rt = make_cli_runtime(static_commands(vec![exe]), Some(exe)); + let reqs = cli_login::requirements( + &[exe, "--list"], + "this should not show (probe exits 0)", + &rt, + ); + assert!( + reqs.is_empty(), + "expected Ready (no requirements) when probe binary resolves and exits 0; \ + got {:?}", + reqs + ); +} + +#[test] +fn cli_login_requirements_logged_out_emits_available() { + // Both adapter and CLI present, but probe exits non-zero (logged out). + // Use the test binary with an unrecognized argument as the probe — + // libtest exits non-zero for unknown flags on all platforms. + // → CliLogin{Available} (tooling installed, needs login). + let exe = present_binary_str(); + let rt = make_cli_runtime(static_commands(vec![exe]), Some(exe)); + let reqs = cli_login::requirements(&[exe, "--buzz-probe-fail-xyz"], "run `tool login`", &rt); + assert!( + !reqs.is_empty(), + "non-zero probe must produce a CliLogin requirement (logged out)" + ); + if let Requirement::CliLogin { + ref availability, .. + } = reqs[0] + { + assert_eq!( + *availability, + crate::managed_agents::AcpAvailabilityStatus::Available, + "tooling installed, probe fails → Available (logged-out)" + ); + } +} + +// ── codex readiness version gate ─────────────────────────────────────── + +/// Build a minimal `KnownAcpRuntime` for testing the codex version gate. +/// `adapter_commands` are the exact strings passed to `find_command` — use +/// `&["codex-acp"]` when the binary is on PATH, or `&[]` +/// when resolving via absolute path. `underlying_cli` is a portable +/// stand-in so the adapter is not misclassified as `CliMissing`. +fn make_codex_runtime( + adapter_commands: &'static [&'static str], + underlying_cli: Option<&'static str>, +) -> KnownAcpRuntime { + KnownAcpRuntime { + id: "codex", + label: "Codex", + commands: adapter_commands, + aliases: &[], + avatar_url: "", + mcp_command: None, + mcp_hooks: false, + underlying_cli, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + install_instructions_url: "", + cli_install_hint: "", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: false, + config_file_path: None, + config_file_format: None, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + default_env: &[], + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: None, + auth_probe_args: None, + } +} + +/// Build a temp dir containing a `codex-acp` script with the given body, +/// prepend it to PATH, and clear the resolve cache. Returns the temp dir +/// and the original PATH string for restoration. +#[cfg(unix)] +fn setup_temp_codex_acp(script_body: &str) -> (tempfile::TempDir, String) { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("create temp dir"); + let bin = dir.path().join("codex-acp"); + std::fs::write(&bin, script_body).expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + + let original_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", dir.path().display(), original_path); + std::env::set_var("PATH", &new_path); + crate::managed_agents::clear_resolve_cache(); + + (dir, original_path) +} + +#[cfg(unix)] +fn leaked_adapter_commands(bin: &std::path::Path) -> &'static [&'static str] { + let command = Box::leak(bin.display().to_string().into_boxed_str()); + Box::leak(vec![command as &'static str].into_boxed_slice()) +} + +/// Restore PATH and clear the resolve cache after a PATH-mutating test. +#[cfg(unix)] +fn restore_path(original: &str) { + std::env::set_var("PATH", original); + crate::managed_agents::clear_resolve_cache(); +} + +/// Codex readiness: outdated adapter (exits non-zero) → AdapterOutdated, +/// login probe skipped. +#[cfg(unix)] +#[test] +fn cli_login_requirements_codex_outdated_adapter_emits_adapter_outdated() { + let _guard = crate::managed_agents::lock_path_mutex(); + + let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\nexit 1\n"); + let exe = present_binary_str(); + // Use the fixture's absolute adapter path here. Bare `codex-acp` + // intentionally prefers Buzz's managed npm shim when it exists, which + // would make this version-gate regression test depend on machine state. + let rt = make_codex_runtime( + leaked_adapter_commands(&dir.path().join("codex-acp")), + Some(exe), + ); + let reqs = cli_login::requirements( + &[exe, "--buzz-probe-must-not-run-xyz"], + "run `codex login`", + &rt, + ); + + restore_path(&orig); + drop(dir); + + assert!( + !reqs.is_empty(), + "outdated codex adapter must produce a requirement; got {reqs:?}" + ); + if let Requirement::CliLogin { + ref availability, .. + } = reqs[0] + { + assert_eq!( + *availability, + crate::managed_agents::AcpAvailabilityStatus::AdapterOutdated, + "0.x codex adapter must yield AdapterOutdated; got {availability:?}" + ); + } else { + panic!("expected CliLogin requirement; got {:?}", reqs[0]); + } +} + +/// Codex readiness: adapter exits 0 but output is not a parseable version +/// → AdapterOutdated (garbage output treated as outdated, same as non-zero). +#[cfg(unix)] +#[test] +fn cli_login_requirements_codex_garbage_version_output_emits_adapter_outdated() { + let _guard = crate::managed_agents::lock_path_mutex(); + + let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\necho 'not a version string'\nexit 0\n"); + let exe = present_binary_str(); + let rt = make_codex_runtime( + leaked_adapter_commands(&dir.path().join("codex-acp")), + Some(exe), + ); + let reqs = cli_login::requirements( + &[exe, "--buzz-probe-must-not-run-xyz"], + "run `codex login`", + &rt, + ); + + restore_path(&orig); + drop(dir); + + assert!( + !reqs.is_empty(), + "garbage version output must produce a requirement; got {reqs:?}" + ); + if let Requirement::CliLogin { + ref availability, .. + } = reqs[0] + { + assert_eq!( + *availability, + crate::managed_agents::AcpAvailabilityStatus::AdapterOutdated, + "unparseable version output must yield AdapterOutdated; got {availability:?}" + ); + } else { + panic!("expected CliLogin requirement; got {:?}", reqs[0]); + } +} + +// ── custom/unknown command ───────────────────────────────────────────── + +#[test] +fn unknown_command_is_always_ready() { + let env = make_env("my-custom-harness", BTreeMap::new()); + assert!( + agent_readiness(&env).is_ready(), + "unknown/custom command should always be Ready (no requirements)" + ); +} + +// ── AgentReadiness helpers ───────────────────────────────────────────── + +#[test] +fn agent_readiness_ready_has_empty_requirements() { + assert!(AgentReadiness::Ready.requirements().is_empty()); +} + +#[test] +fn agent_readiness_not_ready_exposes_requirements() { + let r = AgentReadiness::NotReady { + requirements: vec![Requirement::EnvKey { + key: "FOO".to_string(), + }], + }; + assert!(!r.is_ready()); + assert_eq!(r.requirements().len(), 1); +} + +// ── Requirement serialization ───────────────────────────────────────── + +#[test] +fn requirement_serializes_with_surface_tag() { + let r = Requirement::NormalizedField { + field: "provider".to_string(), + }; + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json["surface"], "normalized_field"); + assert_eq!(json["field"], "provider"); +} + +#[test] +fn git_bash_requirement_serializes_correctly() { + let json = serde_json::to_value(Requirement::GitBash).unwrap(); + assert_eq!(json, serde_json::json!({ "surface": "git_bash" })); +} + +#[test] +fn env_key_requirement_serializes_correctly() { + let r = Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }; + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json["surface"], "env_key"); + assert_eq!(json["key"], "ANTHROPIC_API_KEY"); +} + +#[test] +fn cli_login_requirement_serializes_correctly() { + let r = Requirement::CliLogin { + probe_args: vec![ + "codex".to_string(), + "login".to_string(), + "status".to_string(), + ], + setup_copy: "run `codex login`".to_string(), + availability: crate::managed_agents::AcpAvailabilityStatus::Available, + }; + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json["surface"], "cli_login"); + assert!(json["probe_args"].is_array()); + assert!(json["setup_copy"].as_str().unwrap().contains("codex login")); +} + +// ── resolve_effective_agent_env ───────────────────────────────────────── + +#[test] +fn resolve_effective_agent_env_user_env_wins_over_structured_fields() { + // A record whose env_vars explicitly set provider/model must win over + // any baked defaults. In OSS test builds the baked map is empty, so + // this test validates the user-env layer is present in the output. + let mut env_vars = BTreeMap::new(); + env_vars.insert("BUZZ_AGENT_PROVIDER".to_string(), "anthropic".to_string()); + env_vars.insert( + "BUZZ_AGENT_MODEL".to_string(), + "claude-opus-4-5".to_string(), + ); + + // Minimal record: only the fields resolve_effective_agent_env reads. + let record = crate::managed_agents::types::ManagedAgentRecord { + pubkey: "test-pubkey".to_string(), + name: "test-agent".to_string(), + persona_id: None, + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "buzz-agent".to_string(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars, + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: Default::default(), + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + }; + + let runtime = known_acp_runtime_exact("buzz-agent"); + let effective = resolve_effective_agent_env(&record, &[], runtime, &Default::default()); + + // User env_vars must be present in the output (last-write-wins). + assert_eq!( + effective.env.get("BUZZ_AGENT_PROVIDER").map(String::as_str), + Some("anthropic") + ); + assert_eq!( + effective.env.get("BUZZ_AGENT_MODEL").map(String::as_str), + Some("claude-opus-4-5") + ); +} + +// ── provider-specific model fallback tests ──────────────────────────── + +#[test] +fn buzz_agent_databricks_v2_with_databricks_model_but_no_buzz_agent_model_is_ready() { + // The baked buzz-releases env sets DATABRICKS_MODEL but not BUZZ_AGENT_MODEL. + // An agent with only DATABRICKS_MODEL must pass the readiness gate. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks_v2"), + ("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"), + ("DATABRICKS_HOST", "https://dbc.example.com"), + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "DATABRICKS_MODEL must satisfy the model requirement for databricks_v2" + ); +} + +#[test] +fn buzz_agent_databricks_v2_hyphen_alias_with_databricks_model_is_ready() { + // buzz-agent accepts both "databricks_v2" and "databricks-v2". The + // readiness gate must recognize the hyphen alias and accept DATABRICKS_MODEL. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks-v2"), + ("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"), + ("DATABRICKS_HOST", "https://dbc.example.com"), + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "databricks-v2 alias with DATABRICKS_MODEL must be Ready" + ); +} + +#[test] +fn buzz_agent_databricks_hyphen_alias_missing_host_returns_not_ready() { + // The hyphen alias "databricks-v2" requires DATABRICKS_HOST just like + // the underscore variants. Without it the agent cannot reach the endpoint. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks-v2"), + ("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"), + // DATABRICKS_HOST intentionally absent + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "databricks-v2 without DATABRICKS_HOST must be NotReady" + ); + let reqs = result.requirements(); + assert!( + reqs.iter() + .any(|r| matches!(r, Requirement::EnvKey { key } if key == "DATABRICKS_HOST")), + "missing requirements must include DATABRICKS_HOST; got {reqs:?}" + ); +} + +#[test] +fn buzz_agent_databricks_v1_with_databricks_model_but_no_buzz_agent_model_is_ready() { + // V1 (Model Serving) also resolves DATABRICKS_MODEL — same fallback applies. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks"), + ("DATABRICKS_MODEL", "dbrx-instruct"), + ("DATABRICKS_HOST", "https://dbc.example.com"), + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "DATABRICKS_MODEL must satisfy the model requirement for databricks (V1)" + ); +} + +#[test] +fn buzz_agent_anthropic_with_anthropic_model_but_no_buzz_agent_model_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("ANTHROPIC_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "ANTHROPIC_MODEL must satisfy the model requirement for anthropic" + ); +} + +#[test] +fn buzz_agent_openai_with_openai_compat_model_but_no_buzz_agent_model_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openai"), + ("OPENAI_COMPAT_MODEL", "gpt-4o"), + ("OPENAI_COMPAT_API_KEY", "sk-test"), + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "OPENAI_COMPAT_MODEL must satisfy the model requirement for openai" + ); +} + +#[test] +fn buzz_agent_empty_provider_model_fallback_key_is_not_ready() { + // An empty DATABRICKS_MODEL with no BUZZ_AGENT_MODEL must still be NotReady. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks_v2"), + ("DATABRICKS_MODEL", ""), + ("DATABRICKS_HOST", "https://dbc.example.com"), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty DATABRICKS_MODEL with no BUZZ_AGENT_MODEL must be NotReady" + ); + assert!(result + .requirements() + .contains(&Requirement::NormalizedField { + field: "model".to_string() + })); +} diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index dc4eb2d290..e3bbe403c7 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -490,11 +490,7 @@ export function AgentConfigFields({ inheritedLabel="Provided by this build" isInherited={apiKeyInherited} isRequired={!apiKeyInherited && apiKeyValue.length === 0} - label={ - effectiveProvider === "anthropic" - ? "Anthropic API Key" - : "OpenAI API Key" - } + label="API Key" onValueChange={(value) => onConfigChange({ ...config, diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index d69028cfd1..b7dfc3afef 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -916,11 +916,7 @@ export function AgentDefinitionDialog({ isInherited={apiKeyIsInherited} inheritedLabel={apiKeyInheritedLabel} isRequired={apiKeyIsRequired} - label={ - effectiveProvider === "anthropic" - ? "Anthropic API key" - : "OpenAI API key" - } + label="API Key" onValueChange={(next) => { setEnvVars((prev) => ({ ...prev, diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 8be5f4719b..2c7740c7b6 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -1019,11 +1019,7 @@ export function AgentInstanceEditDialog({ isInherited={apiKeyIsInherited} inheritedLabel={apiKeyInheritedLabel} isRequired={apiKeyIsRequired} - label={ - effectiveProvider === "anthropic" - ? "Anthropic API Key" - : "OpenAI API Key" - } + label="API Key" onValueChange={(next) => { setEnvVars((prev) => ({ ...prev, diff --git a/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx b/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx index 17f9e2e826..164a9c19e0 100644 --- a/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx +++ b/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx @@ -39,7 +39,7 @@ export function PersonaProviderApiKeyField({ inheritedLabel: string; /** True when the key is required and not satisfied anywhere. */ isRequired: boolean; - /** Display label, e.g. "Anthropic API Key". */ + /** Display label, e.g. "API Key". */ label: string; onValueChange: (next: string) => void; /** Current agent-local value of the secret env var. */ diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 5272f559bc..53662921bc 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -19,6 +19,32 @@ export const BLOCK_BUILD_HIDDEN_PROVIDER_IDS: ReadonlySet = new Set([ "databricks", ]); +/** + * Provider ids that only the `buzz-agent` runtime can drive. Goose keys off + * `GOOSE_PROVIDER`/`GOOSE_MODEL` plus its own config and its readiness map + * (`readiness::goose_requirements`) only knows anthropic/openai/databricks — + * it has no `gemini` arm. Offering Gemini for Goose would advertise a provider + * the Goose backend never checks and demand a `GEMINI_API_KEY` it never reads + * (frontend/backend drift). These ids are suppressed from the picker and from + * credential requirements for every runtime except `buzz-agent`; a value + * already persisted with one of them still renders via the `(current)` tail. + */ +export const BUZZ_AGENT_ONLY_PROVIDER_IDS: ReadonlySet = new Set([ + "gemini", +]); + +/** + * Provider ids that only the `goose` runtime can drive. Goose registers a + * native Google Gemini provider under the id `google`, authenticated with + * `GOOGLE_API_KEY` (distinct from buzz-agent's OpenAI-compatible `gemini` / + * `GEMINI_API_KEY` contract — see `BUZZ_AGENT_ONLY_PROVIDER_IDS`). buzz-agent + * has no `google` adapter, so offering it there would advertise a provider the + * buzz-agent backend never checks. These ids are suppressed from the picker and + * from credential requirements for every runtime except `goose`; a value + * already persisted with one of them still renders via the `(current)` tail. + */ +export const GOOSE_ONLY_PROVIDER_IDS: ReadonlySet = new Set(["google"]); + export const PERSONA_FIELD_SHELL_CLASS = "rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50"; export const PERSONA_FIELD_CONTROL_CLASS = @@ -36,6 +62,8 @@ const KNOWN_LLM_PROVIDER_IDS = [ "anthropic", "databricks", "databricks_v2", + "gemini", + "google", "openai", "openai-compat", ] as const; @@ -91,6 +119,16 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial< requiredEnvKeys: ["OPENAI_COMPAT_API_KEY"], secretEnvVar: "OPENAI_COMPAT_API_KEY", }, + gemini: { + requiredEnvKeys: ["GEMINI_API_KEY"], + secretEnvVar: "GEMINI_API_KEY", + }, + google: { + // Goose's native Gemini provider. GOOGLE_HOST is optional (defaults to the + // Google AI host), so only GOOGLE_API_KEY is required. + requiredEnvKeys: ["GOOGLE_API_KEY"], + secretEnvVar: "GOOGLE_API_KEY", + }, databricks: { // DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path. requiredEnvKeys: ["DATABRICKS_HOST"], @@ -116,6 +154,8 @@ export const PERSONA_LLM_PROVIDER_OPTIONS: readonly PersonaModelOption[] = [ { id: "anthropic", label: "Anthropic" }, { id: "openai", label: "OpenAI" }, { id: "openai-compat", label: "OpenAI-compatible" }, + { id: "gemini", label: "Gemini" }, + { id: "google", label: "Google Gemini" }, { id: "relay-mesh", label: "Buzz shared compute" }, { id: "databricks", label: "Databricks" }, { id: "databricks_v2", label: "Databricks v2" }, @@ -158,7 +198,25 @@ export function requiredCredentialEnvKeys( if (normalizedRuntime !== "buzz-agent" && normalizedRuntime !== "goose") { return []; } - const config = PROVIDER_CREDENTIAL_CONFIG[provider.trim().toLowerCase()]; + const normalizedProvider = provider.trim().toLowerCase(); + // Runtime-scoped providers require no credentials on other runtimes — keep + // this in lockstep with the picker gate in getPersonaProviderOptions so + // options and requirements never drift. + // buzz-agent-only providers (e.g. gemini) are unsupported by Goose. + if ( + normalizedRuntime !== "buzz-agent" && + BUZZ_AGENT_ONLY_PROVIDER_IDS.has(normalizedProvider) + ) { + return []; + } + // goose-only providers (e.g. google) are unsupported by buzz-agent. + if ( + normalizedRuntime !== "goose" && + GOOSE_ONLY_PROVIDER_IDS.has(normalizedProvider) + ) { + return []; + } + const config = PROVIDER_CREDENTIAL_CONFIG[normalizedProvider]; return config?.requiredEnvKeys ?? []; } @@ -254,7 +312,9 @@ export function providerRequiresExplicitModel( return ( trimmedProvider === "anthropic" || trimmedProvider === "openai" || - trimmedProvider === "openai-compat" + trimmedProvider === "openai-compat" || + trimmedProvider === "gemini" || + trimmedProvider === "google" ); } @@ -342,9 +402,22 @@ export function getPersonaProviderOptions( const defaultProviderOptions = [ { id: "", label: getDefaultLlmProviderLabel(runtimeId, globalProvider) }, ]; + // Suppress runtime-scoped providers for runtimes that can't drive them: + // buzz-agent-only (e.g. gemini) is hidden everywhere but buzz-agent, and + // goose-only (e.g. google) is hidden everywhere but goose. A value already + // persisted with one still renders via the `(current)` tail below, so an + // existing agent never loses its saved selection. + const trimmedRuntimeId = runtimeId.trim(); + const isBuzzAgentRuntime = trimmedRuntimeId === "buzz-agent"; + const isGooseRuntime = trimmedRuntimeId === "goose"; + const runtimeVisibleOptions = PERSONA_LLM_PROVIDER_OPTIONS.filter((o) => { + if (BUZZ_AGENT_ONLY_PROVIDER_IDS.has(o.id)) return isBuzzAgentRuntime; + if (GOOSE_ONLY_PROVIDER_IDS.has(o.id)) return isGooseRuntime; + return true; + }); const filteredOptions = hideProviderIds?.size - ? PERSONA_LLM_PROVIDER_OPTIONS.filter((o) => !hideProviderIds.has(o.id)) - : PERSONA_LLM_PROVIDER_OPTIONS; + ? runtimeVisibleOptions.filter((o) => !hideProviderIds.has(o.id)) + : runtimeVisibleOptions; const options = [...defaultProviderOptions, ...filteredOptions]; if ( trimmedProvider.length === 0 || diff --git a/desktop/src/features/agents/ui/bakedEnvHelpers.ts b/desktop/src/features/agents/ui/bakedEnvHelpers.ts index 40b3328256..86531464f9 100644 --- a/desktop/src/features/agents/ui/bakedEnvHelpers.ts +++ b/desktop/src/features/agents/ui/bakedEnvHelpers.ts @@ -69,6 +69,8 @@ function providerModelEnvKey(provider: string): string | null { case "openai": case "openai-compat": return "OPENAI_COMPAT_MODEL"; + case "gemini": + return "GEMINI_MODEL"; default: return null; } diff --git a/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs index 32fc7cf582..5f53b4d0f1 100644 --- a/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs +++ b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs @@ -1407,6 +1407,25 @@ test("global model fallback resolves the selected provider model env", () => { ); }); +test("global model fallback resolves the Gemini provider model env", () => { + const bakedEnv = [ + { key: "GEMINI_MODEL", value: "gemini-2.5-pro", masked: false }, + ]; + assert.equal(getGlobalModelFallback(bakedEnv, "gemini"), "gemini-2.5-pro"); +}); + +test("global model fallback gives saved Gemini env precedence over build env", () => { + const bakedEnv = [ + { key: "GEMINI_MODEL", value: "gemini-2.5-flash", masked: false }, + ]; + assert.equal( + getGlobalModelFallback(bakedEnv, "gemini", { + GEMINI_MODEL: "gemini-2.5-pro", + }), + "gemini-2.5-pro", + ); +}); + test("global model fallback gives saved provider env precedence over build env", () => { const bakedEnv = [ { key: "ANTHROPIC_MODEL", value: "build-model", masked: false }, diff --git a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs index 8b1003e951..416bef1fa5 100644 --- a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs +++ b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs @@ -6,6 +6,7 @@ import { getPersonaProviderOptions, requiredCredentialEnvKeys, isMissingRequiredDropdownField, + providerRequiresExplicitModel, } from "./agentConfigOptions.tsx"; import { computeEditAgentFormValidity, @@ -56,7 +57,7 @@ test("editAgent_providerFieldHidden_forBlankRuntime", () => { // ── Provider dropdown options for EditAgentProviderField ──────────────────── // // The provider dropdown contains the well-known providers -// (databricks, databricks_v2, anthropic, openai, openai-compat) plus a +// (databricks, databricks_v2, anthropic, openai, openai-compat, gemini) plus a // default-provider fallback entry so users can clear a saved provider. // // On OSS builds, Databricks v1 ("databricks") is shown alongside v2 so OSS @@ -111,6 +112,104 @@ test("editAgent_providerOptions_includesDatabricksV1AsCurrentEvenWhenHidden", () ); }); +test("editAgent_providerOptions_includesGemini", () => { + const options = getPersonaProviderOptions("", "buzz-agent"); + const ids = options.map((o) => o.id); + assert.ok(ids.includes("gemini"), "gemini must be present in the dropdown"); +}); + +test("editAgent_gemini_requiresApiKeyAndExplicitModel", () => { + // Gemini's credential is GEMINI_API_KEY, and (like OpenAI/Anthropic) it has + // no auto/default model, so an explicit model must be chosen. + assert.deepEqual(requiredCredentialEnvKeys("buzz-agent", "gemini"), [ + "GEMINI_API_KEY", + ]); + assert.ok( + providerRequiresExplicitModel("gemini"), + "gemini must require an explicit model", + ); +}); + +// ── Gemini is buzz-agent-only — must not be advertised for Goose ───────────── +// +// Goose's readiness map (readiness::goose_requirements) has no `gemini` arm, +// so offering Gemini for Goose would demand a GEMINI_API_KEY the Goose backend +// never reads (frontend/backend drift). The picker and credential-requirement +// helpers gate Gemini to buzz-agent; a value already persisted with `gemini` +// still renders via the `(current)` tail so an existing agent never loses it. + +test("editAgent_providerOptions_excludesGeminiForGoose", () => { + const options = getPersonaProviderOptions("", "goose"); + const ids = options.map((o) => o.id); + assert.ok( + !ids.includes("gemini"), + "gemini must not be offered for the goose runtime", + ); +}); + +test("editAgent_providerOptions_includesGeminiAsCurrentForGooseWhenSaved", () => { + // A record already persisted with provider="gemini" on goose must still show + // its saved value via the (current) tail even though gemini is not offered. + const options = getPersonaProviderOptions("gemini", "goose"); + const ids = options.map((o) => o.id); + assert.ok( + ids.includes("gemini"), + "a saved gemini value must render as the current provider on goose", + ); +}); + +test("editAgent_gemini_requiresNoCredentialsForGoose", () => { + // Goose can't drive gemini, so it requires no gemini credentials there. + assert.deepEqual(requiredCredentialEnvKeys("goose", "gemini"), []); +}); + +test("editAgent_gooseSupportedProviderCredentialsUnchanged", () => { + // The gate is narrow: providers Goose does support keep their credentials. + assert.deepEqual(requiredCredentialEnvKeys("goose", "anthropic"), [ + "ANTHROPIC_API_KEY", + ]); +}); + +// ── Google Gemini is goose-only — must not be advertised for buzz-agent ────── +// +// Goose registers a native Gemini provider under the id `google`, authenticated +// with GOOGLE_API_KEY (distinct from buzz-agent's OpenAI-compatible `gemini` / +// GEMINI_API_KEY). buzz-agent has no `google` adapter, so the picker and +// credential-requirement helpers gate `google` to the goose runtime. + +test("editAgent_providerOptions_includesGoogleForGoose", () => { + const options = getPersonaProviderOptions("", "goose"); + const ids = options.map((o) => o.id); + assert.ok( + ids.includes("google"), + "google (Goose's native Gemini provider) must be offered for goose", + ); +}); + +test("editAgent_providerOptions_excludesGoogleForBuzzAgent", () => { + const options = getPersonaProviderOptions("", "buzz-agent"); + const ids = options.map((o) => o.id); + assert.ok( + !ids.includes("google"), + "google must not be offered for the buzz-agent runtime", + ); +}); + +test("editAgent_google_requiresGoogleApiKeyAndExplicitModelForGoose", () => { + assert.deepEqual(requiredCredentialEnvKeys("goose", "google"), [ + "GOOGLE_API_KEY", + ]); + assert.ok( + providerRequiresExplicitModel("google"), + "google must require an explicit model", + ); +}); + +test("editAgent_google_requiresNoCredentialsForBuzzAgent", () => { + // buzz-agent can't drive google, so it requires no google credentials there. + assert.deepEqual(requiredCredentialEnvKeys("buzz-agent", "google"), []); +}); + test("editAgent_providerOptions_includesDefaultEntry", () => { const options = getPersonaProviderOptions("", "buzz-agent"); // The first entry is the default (empty id) — clearing back to runtime default. diff --git a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs index bf21bc92ff..5a4bac7041 100644 --- a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs +++ b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs @@ -25,6 +25,27 @@ test("model discovery status names missing OpenAI-compatible credentials", () => assert.match(status?.message ?? "", /OpenAI models/); }); +test("model discovery status names missing Gemini credentials", () => { + const status = formatModelDiscoveryErrorStatus( + new Error("config: GEMINI_API_KEY required"), + "gemini", + ); + + assert.equal(status?.tone, "warning"); + assert.match(status?.message ?? "", /Gemini API key/); + assert.match(status?.message ?? "", /Gemini models/); +}); + +test("model discovery status labels Gemini in the generic fallback", () => { + const status = formatModelDiscoveryErrorStatus( + new Error("gemini discovery hit an unexpected transport error"), + "gemini", + ); + + assert.equal(status?.tone, "warning"); + assert.match(status?.message ?? "", /Gemini/); +}); + test("Buzz shared compute names the empty state and next action", () => { const status = formatModelDiscoveryErrorStatus( new Error("no Buzz shared compute serving members are available"), diff --git a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts index 38138429f8..052a3a3469 100644 --- a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts +++ b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts @@ -25,6 +25,8 @@ function providerObjectLabel(provider: string): string { return "OpenAI"; case "openai-compat": return "OpenAI-compatible"; + case "gemini": + return "Gemini"; default: return provider.trim() || "this provider"; } @@ -101,6 +103,13 @@ export function formatModelDiscoveryErrorStatus( }; } + if (message.includes("GEMINI_API_KEY required")) { + return { + message: "Enter a Gemini API key to load Gemini models.", + tone: "warning", + }; + } + if ( message.includes("DATABRICKS_HOST required") || message.includes("DATABRICKS_MODEL required") || diff --git a/desktop/tests/e2e/agent-readiness-screenshots.spec.ts b/desktop/tests/e2e/agent-readiness-screenshots.spec.ts index 856cc67422..9b55ca2288 100644 --- a/desktop/tests/e2e/agent-readiness-screenshots.spec.ts +++ b/desktop/tests/e2e/agent-readiness-screenshots.spec.ts @@ -180,7 +180,7 @@ test.describe("agent readiness gate screenshots", () => { await selectProvider(page, "Anthropic"); await setCustomModel(page, "claude-opus-4-5"); - await expect(page.getByLabel("Anthropic API Key")).toBeVisible(); + await expect(page.getByTestId("persona-provider-api-key")).toBeVisible(); await expect(page.getByTestId("persona-dialog-submit")).toBeDisabled({ timeout: 10_000, }); @@ -218,7 +218,9 @@ test.describe("agent readiness gate screenshots", () => { await openCreateDialog(page); await selectProvider(page, "Anthropic"); await setCustomModel(page, "claude-opus-4-5"); - await page.getByLabel("Anthropic API Key").fill("sk-test-api-key-for-e2e"); + await page + .getByTestId("persona-provider-api-key") + .fill("sk-test-api-key-for-e2e"); await expect(page.getByTestId("persona-dialog-submit")).toBeEnabled({ timeout: 5_000, diff --git a/desktop/tests/e2e/edit-agent.spec.ts b/desktop/tests/e2e/edit-agent.spec.ts index 4b4036e66c..3b54f07730 100644 --- a/desktop/tests/e2e/edit-agent.spec.ts +++ b/desktop/tests/e2e/edit-agent.spec.ts @@ -134,7 +134,9 @@ test.describe("edit agent dialog", () => { await pickDropdownOption(page, "edit-agent-model", "Custom model..."); await page.locator("#edit-agent-custom-model").fill("claude-opus-4-5"); // Anthropic requires a credential before save unlocks. - await page.getByLabel("Anthropic API Key").fill("sk-test-edit-agent-e2e"); + await page + .getByTestId("persona-provider-api-key") + .fill("sk-test-edit-agent-e2e"); const submit = page.getByTestId("edit-agent-dialog-submit"); await expect(submit).toBeEnabled({ timeout: 10_000 }); diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index da4214058e..ee6257adb2 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -212,7 +212,7 @@ test.describe("global agent config screenshots", () => { await openCreateDialog(page); await customizeAgentAi(page); - await expect(page.getByLabel("Anthropic API Key")).toBeVisible({ + await expect(page.getByTestId("persona-provider-api-key")).toBeVisible({ timeout: 10_000, }); await expect( @@ -233,7 +233,7 @@ test.describe("global agent config screenshots", () => { await openCreateDialog(page); await customizeAgentAi(page); - await expect(page.getByLabel("Anthropic API Key")).toHaveAttribute( + await expect(page.getByTestId("persona-provider-api-key")).toHaveAttribute( "placeholder", "Inherited from global config", ); diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index 3c2acae096..a638f3bff3 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -966,7 +966,7 @@ test("config page shows Agent defaults form", async ({ page }) => { ).toHaveCount(0); await page.keyboard.press("Escape"); await expect(page.getByText("Applies to all agents")).toHaveCount(0); - await expect(page.getByLabel("OpenAI API Key")).toHaveCount(0); + await expect(page.getByTestId("persona-provider-api-key")).toHaveCount(0); await expect(effortSelect).toBeVisible(); await expect( page.getByText( @@ -1338,8 +1338,7 @@ test("compact default config still persists rapid provider edits", async ({ await chooseConfigDropdownOption(page, "global-agent-provider", "anthropic"); await expect(providerSelect).toHaveAttribute("data-value", "anthropic"); - await expect(page.getByLabel("Anthropic API Key")).toBeVisible(); - await expect(page.getByLabel("OpenAI API Key")).toHaveCount(0); + await expect(page.getByTestId("persona-provider-api-key")).toBeVisible(); await expect(page.getByLabel("Value for DATABRICKS_HOST")).toHaveCount(0); }); diff --git a/desktop/tests/e2e/persona-env-vars.spec.ts b/desktop/tests/e2e/persona-env-vars.spec.ts index 53efa09a0e..7bff355b9d 100644 --- a/desktop/tests/e2e/persona-env-vars.spec.ts +++ b/desktop/tests/e2e/persona-env-vars.spec.ts @@ -329,7 +329,7 @@ test("persona model options follow the selected LLM provider", async ({ await selectDropdownOption(page, llmProvider, "OpenAI"); const dialog = page.getByRole("dialog"); - await expect(dialog.getByLabel("OpenAI API Key")).toBeVisible(); + await expect(dialog.getByTestId("persona-provider-api-key")).toBeVisible(); await expect( dialog.getByRole("button", { name: "Advanced", exact: true }), ).toHaveAttribute("aria-expanded", "false"); @@ -342,14 +342,19 @@ test("persona model options follow the selected LLM provider", async ({ .click(); await selectDropdownOption(page, llmProvider, "Anthropic"); - await expect(dialog.getByLabel("Anthropic API Key")).toBeVisible(); - await expect(dialog.getByLabel("OpenAI API Key")).not.toBeVisible(); + await expect(dialog.getByTestId("persona-provider-api-key")).toBeVisible(); + await expect(model).toBeVisible(); + + await selectDropdownOption(page, llmProvider, "Gemini"); + await expect(dialog.getByTestId("persona-provider-api-key")).toBeVisible(); await expect(model).toBeVisible(); // Switch back to inherited defaults — per-agent provider, credential, and // model controls disappear together. await page.getByRole("tab", { name: "Use agent defaults" }).click(); await expect(llmProvider).not.toBeVisible(); - await expect(dialog.getByLabel("Anthropic API Key")).not.toBeVisible(); + await expect( + dialog.getByTestId("persona-provider-api-key"), + ).not.toBeVisible(); await expect(model).not.toBeVisible(); }); diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index d5ae2046db..9e016cfd93 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -236,7 +236,9 @@ test("create agent supports parallelism and system prompt overrides", async ({ .getByRole("button", { name: "Custom model...", exact: true }) .click(); await page.getByLabel("Custom model ID").fill("claude-opus-4-5"); - await page.getByLabel("Anthropic API Key").fill("sk-test-api-key-for-e2e"); + await page + .getByTestId("persona-provider-api-key") + .fill("sk-test-api-key-for-e2e"); const advancedToggle = page.getByRole("button", { name: "Advanced", diff --git a/desktop/tests/e2e/welcome-agent-modal-screenshots.spec.ts b/desktop/tests/e2e/welcome-agent-modal-screenshots.spec.ts index 043efa0035..d6310838c1 100644 --- a/desktop/tests/e2e/welcome-agent-modal-screenshots.spec.ts +++ b/desktop/tests/e2e/welcome-agent-modal-screenshots.spec.ts @@ -153,7 +153,9 @@ test.describe("welcome and channel agent entry points", () => { .getByRole("button", { name: "Custom model...", exact: true }) .click(); await page.getByLabel("Custom model ID").fill("claude-opus-4-5"); - await page.getByLabel("Anthropic API Key").fill("sk-test-api-key-for-e2e"); + await page + .getByTestId("persona-provider-api-key") + .fill("sk-test-api-key-for-e2e"); await expect(page.getByTestId("persona-dialog-submit")).toBeEnabled(); await page.getByTestId("persona-dialog-submit").click();