diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index f8373bd66d8..83ef9246d5e 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -210,12 +210,62 @@ pub struct AcpClient { steer_rx: Option>, /// Usage tracker for goose/buzz-agent's cumulative notification format. goose_usage: UsageTracker, + /// Plain assistant text streamed during the current turn, and whether the + /// agent published a reply itself. Small local models routinely finish a + /// multi-step turn by writing the answer as prose instead of calling + /// `send_message`; ACP treats streamed text as observability only, so that + /// answer is dropped. The pool's mesh-gated delivery fallback consumes + /// these via [`take_undelivered_turn_text`](Self::take_undelivered_turn_text). + turn_text: String, + /// Whether a message-publish tool call was observed during this turn. + turn_published: bool, /// Per-turn prompt-response usage and Claude's optional cumulative cost. standard_usage: StandardUsageTracker, /// Known adapter identity for prompt-response usage mapping. standard_adapter: Option, } +/// Maximum assistant text retained per turn for the delivery fallback. +/// +/// Bounded so a rogue agent streaming forever cannot grow the buffer without +/// limit; a chat reply that exceeds this is truncated rather than dropped. +const MAX_TURN_TEXT_BYTES: usize = 8 * 1024; + +/// Whether an ACP `tool_call` update represents the agent publishing a message. +/// +/// Matches both shapes an agent can use: the first-class `send_message` tool +/// (any `dev__`/server prefix) and a shell command that runs the CLI directly. +fn is_message_publish(title: &str, raw_input: &str) -> bool { + title.ends_with("send_message") + || raw_input.contains("messages send") + || raw_input.contains("social publish") +} + +/// Whether text is a bare acknowledgement not worth publishing as a reply. +/// +/// Guards the fallback against posting filler like "OK" or "Done." when the +/// agent had nothing substantive to say. +fn is_bare_acknowledgement(text: &str) -> bool { + let normalized: String = text + .chars() + .filter(|c| c.is_alphanumeric() || c.is_whitespace()) + .collect::() + .trim() + .to_ascii_lowercase(); + matches!( + normalized.as_str(), + "" | "ok" + | "okay" + | "done" + | "sure" + | "got it" + | "understood" + | "acknowledged" + | "will do" + | "thanks" + ) +} + /// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape /// collisions. When both sides have an object for the same key, the merge recurses so /// unrelated nested keys from `base` are preserved. @@ -561,6 +611,8 @@ impl AcpClient { steering_supported: false, steer_rx: None, goose_usage: UsageTracker::default(), + turn_text: String::new(), + turn_published: false, standard_usage: StandardUsageTracker::default(), standard_adapter, }) @@ -791,6 +843,11 @@ impl AcpClient { self.goose_usage.begin_turn(session_id); self.standard_usage.begin_turn(session_id); + // Reset per-turn delivery-fallback state alongside usage: text streamed + // by a previous turn must never be republished by this one. + self.turn_text.clear(); + self.turn_published = false; + self.last_prompt_id = Some(self.next_id); let id = self.next_id; self.next_id += 1; @@ -901,6 +958,23 @@ impl AcpClient { self.standard_usage.seed_zero_baseline(session_id); } + /// Take the turn's assistant text if the agent never published a reply. + /// + /// Returns `None` when the agent published via `send_message` (or the CLI) + /// itself, or when the only text was a bare acknowledgement. Consuming + /// clears the buffer so the same text can never be posted twice. + pub fn take_undelivered_turn_text(&mut self) -> Option { + let text = std::mem::take(&mut self.turn_text); + if self.turn_published { + return None; + } + let trimmed = text.trim(); + if trimmed.is_empty() || is_bare_acknowledgement(trimmed) { + return None; + } + Some(trimmed.to_string()) + } + /// Install a per-turn steer request channel for goose-native /// non-cancelling mid-turn delivery. /// @@ -1756,6 +1830,15 @@ impl AcpClient { "agent_message_chunk" => { if let Some(text) = update["content"]["text"].as_str() { tracing::info!(target: "acp::stream", "{text}"); + // Retain for the mesh-gated delivery fallback (bounded). + let remaining = MAX_TURN_TEXT_BYTES.saturating_sub(self.turn_text.len()); + if remaining > 0 { + let mut end = text.len().min(remaining); + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + self.turn_text.push_str(&text[..end]); + } } false } @@ -1769,6 +1852,15 @@ impl AcpClient { .and_then(|v| v.as_str()) .unwrap_or("unknown"); tracing::info!(target: "acp::tool", "tool_call: {title} ({kind})"); + if !self.turn_published { + let raw_input = update + .get("rawInput") + .map(|v| v.to_string()) + .unwrap_or_default(); + if is_message_publish(title, &raw_input) { + self.turn_published = true; + } + } true } "tool_call_update" => { @@ -2330,6 +2422,43 @@ fn configure_no_window(cmd: &mut tokio::process::Command) { mod tests { use super::*; + #[test] + fn publish_detection_matches_tool_and_shell_shapes() { + // First-class tool, with the MCP server prefix the agent reports. + assert!(is_message_publish("dev__send_message", "{}")); + assert!(is_message_publish("send_message", "{}")); + // Shell shapes: the CLI invoked directly. + assert!(is_message_publish( + "dev__shell", + r#"{"command":"buzz messages send --channel c --content hi"}"# + )); + assert!(is_message_publish( + "dev__shell", + r#"{"command":"buzz social publish --content hi"}"# + )); + // Unrelated tools must not suppress the fallback. + assert!(!is_message_publish("dev__todo", "{}")); + assert!(!is_message_publish("dev__shell", r#"{"command":"ls -la"}"#)); + // `read_file` ends in neither name and must not match. + assert!(!is_message_publish("dev__read_file", r#"{"path":"a"}"#)); + } + + #[test] + fn bare_acknowledgements_are_not_worth_publishing() { + for text in ["OK", "ok.", "Done!", " Sure ", "Got it", "will do", ""] { + assert!( + is_bare_acknowledgement(text), + "expected {text:?} to be a bare ack" + ); + } + for text in ["12", "The capital is Paris.", "OK, the answer is 12"] { + assert!( + !is_bare_acknowledgement(text), + "expected {text:?} to be substantive" + ); + } + } + #[test] fn stop_reason_parses_all_known_values() { assert_eq!(StopReason::from_str("end_turn"), Some(StopReason::EndTurn)); diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index f9e7bf1ed8a..b984cb29318 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -409,6 +409,17 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_NO_BASE_PROMPT")] pub no_base_prompt: bool, + /// Publish the agent's plain reply text when it ends a turn without calling + /// `send_message`. + /// + /// Off by default: capable models always publish their own replies, and + /// posting streamed text unconditionally would double-post. Small local + /// models served over shared compute reliably finish multi-step turns by + /// writing the answer as prose instead, so the desktop shared-compute + /// preset opts in. + #[arg(long, env = "BUZZ_ACP_DELIVER_PLAIN_REPLIES")] + pub deliver_plain_replies: bool, + /// Path to a custom base prompt file. Overrides the compiled-in default. /// Mutually exclusive with --no-base-prompt. #[arg( @@ -579,6 +590,9 @@ pub struct Config { /// `from_cli()`. `None` when using the compiled-in default or when /// `--no-base-prompt` is set. pub base_prompt_content: Option, + /// Publish the agent's plain reply text when a turn ends without a + /// `send_message` call. Opt-in; see the CLI flag for rationale. + pub deliver_plain_replies: bool, } /// Maximum length, in characters, of a session title sent to the adapter. @@ -1122,6 +1136,7 @@ impl Config { agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), no_base_prompt: args.no_base_prompt, base_prompt_content, + deliver_plain_replies: args.deliver_plain_replies, }; Ok(config) @@ -1493,6 +1508,7 @@ mod tests { idle_pool_sleep_secs: 0, agent_owner: None, no_base_prompt: false, + deliver_plain_replies: false, base_prompt_content: None, } } diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 2a41ea73420..b33339ef570 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2205,6 +2205,7 @@ async fn tokio_main() -> Result<()> { .as_deref() .and_then(|hex| nostr::PublicKey::from_hex(hex).ok()), memory_enabled: config.memory_enabled, + deliver_plain_replies: config.deliver_plain_replies, harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), relay_url: config.relay_url.clone(), }); @@ -7152,6 +7153,7 @@ mod build_mcp_servers_tests { idle_pool_sleep_secs: 0, agent_owner: None, no_base_prompt: false, + deliver_plain_replies: false, base_prompt_content: None, } } @@ -7375,6 +7377,7 @@ mod error_outcome_emission_tests { idle_pool_sleep_secs: 0, agent_owner: None, no_base_prompt: false, + deliver_plain_replies: false, base_prompt_content: None, } } diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 2efacce2b19..52b81cf5a1f 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -599,6 +599,11 @@ pub struct PromptContext { /// `[Agent Memory — core]` section. On by default; disabled via /// `--no-memory` / `BUZZ_ACP_NO_MEMORY`. pub memory_enabled: bool, + /// Whether to publish the agent's plain reply text when a turn ends without + /// a `send_message` call. Off by default; the desktop shared-compute preset + /// opts in because small local models routinely finish a multi-step turn by + /// writing the answer as prose instead of calling the tool. + pub deliver_plain_replies: bool, /// Harness identity string for NIP-AM `harness` field. Derived from the /// configured `agent_command` at startup (e.g. `"goose"`, `"buzz-agent"`). pub harness_name: String, @@ -1503,6 +1508,14 @@ pub async fn run_prompt_task( .as_ref() .map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect()) .unwrap_or_default(); + // Capture the reply target now: `batch` is moved into the requeue/outcome + // arms below, but the plain-reply fallback runs after the turn completes. + // Heartbeats have no batch and therefore no fallback target. + let plain_reply_target = if ctx.deliver_plain_replies { + batch.as_ref().and_then(plain_reply_target_for) + } else { + None + }; agent.acp.observe( "turn_started", serde_json::json!({ @@ -2406,6 +2419,31 @@ pub async fn run_prompt_task( agent.state.invalidate(&source); } + // Deliver the turn's plain text when the agent ended the turn + // without publishing a reply itself. Gated on EndTurn only: + // MaxTokens / MaxTurnRequests mean the turn was truncated, so the + // text is not a deliberate answer. `take_*` clears the buffer, so + // this can never post the same text twice. + if matches!(stop_reason, StopReason::EndTurn) { + if let Some(target) = plain_reply_target.as_ref() { + if let Some(text) = agent.acp.take_undelivered_turn_text() { + tracing::warn!( + target: "pool::prompt", + channel = %target.channel_id, + "agent ended turn without publishing; delivering its plain text as a channel reply" + ); + post_threaded_message( + &ctx.rest_client, + target.channel_id, + &target.thread_tags, + &text, + "plain-reply delivery", + ) + .await; + } + } + } + let core_stop = acp_stop_to_core(&stop_reason); let usage = agent.acp.take_turn_usage(); publish_agent_turn_metric( @@ -4237,6 +4275,34 @@ pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str } } +/// Where a plain-reply fallback should post, captured before `batch` is moved. +#[derive(Debug, Clone)] +pub(crate) struct PlainReplyTarget { + channel_id: Uuid, + thread_tags: ThreadTags, +} + +/// Derive the plain-reply target from the batch's triggering event. +/// +/// Anchors to the trigger's own thread when it is itself a reply; otherwise the +/// trigger becomes the thread root, so the fallback reply lands in the same +/// place the agent's own `send_message` would have. +pub(crate) fn plain_reply_target_for(batch: &FlushBatch) -> Option { + let last = batch.events.last()?; + let parsed = crate::queue::parse_thread_tags(&last.event); + let trigger_hex = last.event.id.to_hex(); + let root = parsed.root_event_id.clone().unwrap_or(trigger_hex.clone()); + let parent = parsed.parent_event_id.clone().unwrap_or(trigger_hex); + Some(PlainReplyTarget { + channel_id: batch.channel_id, + thread_tags: ThreadTags { + root_event_id: Some(root), + parent_event_id: Some(parent), + mentioned_pubkeys: Vec::new(), + }, + }) +} + /// Best-effort: post a visible failure notice (kind:9) to a channel after a /// batch is dead-lettered. Replies into the thread of `thread_tags` when the /// triggering event was threaded. Errors are logged and swallowed — the @@ -4246,6 +4312,21 @@ pub(crate) async fn post_failure_notice( channel_id: Uuid, thread_tags: &ThreadTags, content: &str, +) { + post_threaded_message(rest, channel_id, thread_tags, content, "failure notice").await; +} + +/// Best-effort: post a threaded kind:9 message to a channel. +/// +/// Shared by the dead-letter failure notice and the plain-reply delivery +/// fallback; `what` only labels the log lines so the two are distinguishable. +/// Errors are logged and swallowed — publishing must never take down the loop. +async fn post_threaded_message( + rest: &crate::relay::RestClient, + channel_id: Uuid, + thread_tags: &ThreadTags, + content: &str, + what: &str, ) { let thread_ref = thread_tags.root_event_id.as_deref().and_then(|root| { let root_id = nostr::EventId::from_hex(root).ok()?; @@ -4263,21 +4344,21 @@ pub(crate) async fn post_failure_notice( match buzz_sdk::build_message(channel_id, content, thread_ref.as_ref(), &[], false, &[]) { Ok(b) => b, Err(e) => { - tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}"); + tracing::warn!(channel = %channel_id, "{what}: build failed: {e}"); return; } }; let event = match builder.sign_with_keys(&rest.keys) { Ok(e) => e, Err(e) => { - tracing::warn!(channel = %channel_id, "failure notice: sign failed: {e}"); + tracing::warn!(channel = %channel_id, "{what}: sign failed: {e}"); return; } }; match tokio::time::timeout(Duration::from_secs(5), rest.submit_event(&event)).await { Ok(Ok(_)) => {} - Ok(Err(e)) => tracing::warn!(channel = %channel_id, "failure notice failed: {e}"), - Err(_) => tracing::warn!(channel = %channel_id, "failure notice timed out"), + Ok(Err(e)) => tracing::warn!(channel = %channel_id, "{what} failed: {e}"), + Err(_) => tracing::warn!(channel = %channel_id, "{what} timed out"), } } @@ -7598,6 +7679,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" agent_keys: agent_keys.clone(), agent_owner_pubkey: owner_pubkey, memory_enabled: false, + deliver_plain_replies: false, harness_name: "goose".to_string(), relay_url: "ws://127.0.0.1:3000".to_string(), } diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index 9ae125a0b76..21c73f774c6 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -285,7 +285,10 @@ impl McpRegistry { t.description.as_deref().unwrap_or("").to_owned(), MAX_DESCRIPTION_BYTES, ), - input_schema: cap_schema(&qname, Value::Object((*t.input_schema).clone())), + input_schema: cap_schema( + &qname, + sanitize_tool_schema(Value::Object((*t.input_schema).clone())), + ), }); reg.by_qname.insert(qname, Entry { server_idx, bare }); } @@ -860,6 +863,61 @@ fn timeout_msg(stage: &str, name: &str, t: Duration) -> String { format!("{stage} {name}: timeout after {}s", t.as_secs()) } +/// JSON Schema keywords that are pure metadata or format/range assertions, +/// not part of the safe common subset every OpenAI-compatible function-calling +/// backend accepts. `rmcp`/`schemars`-derived tool schemas routinely carry +/// `$schema`, `format` (e.g. `"format": "uint32"` on integer fields), and +/// similar — some gateways hard-reject a tool schema for carrying them at all +/// ("unsupported assertions or reserved metadata"), even though they're +/// harmless everywhere else. Stripping is always safe: every key here only +/// narrows or documents what the schema accepts, never widens it, so removal +/// can loosen validation but never break a previously-valid tool call. +const SCHEMA_DENYLIST: &[&str] = &[ + "$schema", + "$id", + "$comment", + "title", + "examples", + "default", + "readOnly", + "writeOnly", + "deprecated", + "format", + "pattern", + "minLength", + "maxLength", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "minItems", + "maxItems", + "uniqueItems", + "minProperties", + "maxProperties", + "additionalProperties", + "contentEncoding", + "contentMediaType", +]; + +/// Recursively strip [`SCHEMA_DENYLIST`] keys from every object in the schema +/// tree, applied once at tool registration so every downstream request +/// builder (`anthropic_body`, `openai_body`, `responses_body`) inherits the +/// sanitized form without re-deriving it per turn. +fn sanitize_tool_schema(value: Value) -> Value { + match value { + Value::Object(map) => Value::Object( + map.into_iter() + .filter(|(k, _)| !SCHEMA_DENYLIST.contains(&k.as_str())) + .map(|(k, v)| (k, sanitize_tool_schema(v))) + .collect(), + ), + Value::Array(arr) => Value::Array(arr.into_iter().map(sanitize_tool_schema).collect()), + other => other, + } +} + fn cap_schema(qname: &str, schema: Value) -> Value { let size = serde_json::to_vec(&schema).map(|b| b.len()).unwrap_or(0); if size <= MAX_SCHEMA_BYTES { @@ -1201,4 +1259,91 @@ mod content_tests { // the resulting spawn wouldn't OOM (build+flag-set is the full contract here). // The real protection is the cfg-gated production path in spawn_one(). } + + #[test] + fn sanitize_tool_schema_strips_root_metadata() { + let schema = serde_json::json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ShellArgs", + "type": "object", + "properties": {}, + }); + let out = sanitize_tool_schema(schema); + assert_eq!( + out, + serde_json::json!({ "type": "object", "properties": {} }) + ); + } + + #[test] + fn sanitize_tool_schema_strips_nested_property_assertions() { + // schemars commonly emits "format" on integer fields (e.g. "uint32") + // and range/length assertions — these must be stripped wherever they + // appear in the tree, not just at the root. + let schema = serde_json::json!({ + "type": "object", + "properties": { + "timeout_ms": { "type": "integer", "format": "uint32", "minimum": 0 }, + "command": { "type": "string", "minLength": 1, "pattern": "^.+$" }, + }, + "additionalProperties": false, + }); + let out = sanitize_tool_schema(schema); + assert_eq!( + out, + serde_json::json!({ + "type": "object", + "properties": { + "timeout_ms": { "type": "integer" }, + "command": { "type": "string" }, + }, + }) + ); + } + + #[test] + fn sanitize_tool_schema_preserves_structural_and_semantic_keywords() { + // type/properties/required/items/description/enum are load-bearing — + // they must survive untouched. + let schema = serde_json::json!({ + "type": "object", + "description": "does a thing", + "properties": { + "mode": { "type": "string", "enum": ["fast", "slow"] }, + "tags": { "type": "array", "items": { "type": "string" } }, + }, + "required": ["mode"], + }); + let out = sanitize_tool_schema(schema.clone()); + assert_eq!(out, schema, "no safe keyword should be removed"); + } + + #[test] + fn sanitize_tool_schema_recurses_into_arrays() { + // anyOf/array-of-schemas members must each be sanitized too. + let schema = serde_json::json!({ + "properties": { + "x": { + "anyOf": [ + { "type": "string", "format": "uuid" }, + { "type": "null" } + ] + } + } + }); + let out = sanitize_tool_schema(schema); + assert_eq!( + out, + serde_json::json!({ + "properties": { + "x": { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ] + } + } + }) + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c0055109077..12acc28c77b 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -278,6 +278,29 @@ fn resolve_effective_agent_env_with_def( effective_model.as_deref(), ); + // The user's own self-hosted OpenAI-compatible endpoint (llama.cpp, vLLM, + // a directly-configured mesh-llm instance, etc. — distinct from the + // built-in `relay-mesh` preset above) shares relay-mesh's failure mode: + // small local models often finish a turn by writing the answer as prose + // instead of calling `send_message`, and unlike cloud providers there is + // no reliable way to make them self-correct. Opt this provider into the + // same harness delivery fallback, unless a lower layer already set an + // explicit value. An empty string counts as unset, matching the emptiness + // convention used throughout this module (e.g. `buzz_agent_requirements`). + // Scoped to `openai-compat` only: plain `openai` is the real OpenAI cloud + // API, which — like other cloud providers — reliably calls tools, so + // enabling the fallback there would risk double-posting. + if effective_provider.as_deref().map(str::trim) == Some("openai-compat") + && env + .get("BUZZ_ACP_DELIVER_PLAIN_REPLIES") + .is_none_or(|v| v.trim().is_empty()) + { + env.insert( + "BUZZ_ACP_DELIVER_PLAIN_REPLIES".to_string(), + "true".to_string(), + ); + } + EffectiveAgentEnv { env, config_file_path: runtime.and_then(|r| r.config_file_path), @@ -651,1087 +674,18 @@ fn goose_requirements( } // ── Tests ───────────────────────────────────────────────────────────────────── - +// +// The bulk of readiness's test coverage lives in a sibling file so this +// module stays under the desktop file-size ratchet. #[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() - })); - } +#[path = "readiness_tests.rs"] +mod tests; - #[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: &[], - cli_install_instructions_url: "", - adapter_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, - max_rounds_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: absolute path so `find_command` resolves - /// it via `path.exists()`. Leaked allocation is intentional — process exits after tests. - 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: &[], - cli_install_instructions_url: "", - adapter_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, - max_rounds_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() { - // Since Phase B-7 (readiness exec-check), unknown/custom commands that are - // not resolvable in PATH produce a MissingBinary requirement rather than - // being unconditionally Ready. A command that IS resolvable should be Ready. - // Use a known-present binary so the test is not environment-sensitive. - let env = make_env("sh", BTreeMap::new()); - assert!( - agent_readiness(&env).is_ready(), - "unknown/custom command present in PATH should be Ready" - ); - } - - #[test] - fn unknown_command_missing_from_path_is_not_ready() { - let env = make_env("my-custom-harness-that-does-not-exist", BTreeMap::new()); - let readiness = agent_readiness(&env); - assert!( - !readiness.is_ready(), - "unknown/custom command absent from PATH should be NotReady" - ); - let reqs = readiness.requirements(); - assert_eq!(reqs.len(), 1); - assert!( - matches!(&reqs[0], Requirement::MissingBinary { command } if command == "my-custom-harness-that-does-not-exist"), - "should surface MissingBinary requirement" - ); - } - - // ── 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_policy_pending: false, - 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, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: 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") - ); - } - - #[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() - })); - } - - // ── OpenRouter readiness ───────────────────────────────────────────── - - #[test] - fn buzz_agent_openrouter_with_all_fields_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), - ("OPENROUTER_API_KEY", "sk-or-test-key"), - ]), - ); - let result = agent_readiness(&env); - assert!( - result.is_ready(), - "openrouter with all fields should be ready" - ); - } - - #[test] - fn buzz_agent_openrouter_missing_key_returns_not_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), - ]), - ); - let result = agent_readiness(&env); - assert!(!result.is_ready()); - assert!(result.requirements().contains(&Requirement::EnvKey { - key: "OPENROUTER_API_KEY".to_string() - })); - } - - #[test] - fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { - let env = make_env( - "buzz-agent", - env_with(&[ - ("BUZZ_AGENT_PROVIDER", "openrouter"), - ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), - ("OPENROUTER_API_KEY", "sk-or-test-key"), - ]), - ); - let result = agent_readiness(&env); - assert!( - result.is_ready(), - "OPENROUTER_MODEL fallback should satisfy model requirement" - ); - } -} +// Provider/model-fallback and CLI-adjacent requirement tests continue in a +// second sibling file — the split above still landed over the new-file cap. +#[cfg(test)] +#[path = "readiness_provider_tests.rs"] +mod provider_tests; // Goose file-config-aware requirement tests live in a sibling file so this // module stays under the desktop file-size ratchet. diff --git a/desktop/src-tauri/src/managed_agents/readiness_provider_tests.rs b/desktop/src-tauri/src/managed_agents/readiness_provider_tests.rs new file mode 100644 index 00000000000..a8d1b5c8721 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness_provider_tests.rs @@ -0,0 +1,472 @@ +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() +} + +// ── custom/unknown command ───────────────────────────────────────────── + +#[test] +fn unknown_command_is_always_ready() { + // Since Phase B-7 (readiness exec-check), unknown/custom commands that are + // not resolvable in PATH produce a MissingBinary requirement rather than + // being unconditionally Ready. A command that IS resolvable should be Ready. + // Use a known-present binary so the test is not environment-sensitive. + let env = make_env("sh", BTreeMap::new()); + assert!( + agent_readiness(&env).is_ready(), + "unknown/custom command present in PATH should be Ready" + ); +} + +#[test] +fn unknown_command_missing_from_path_is_not_ready() { + let env = make_env("my-custom-harness-that-does-not-exist", BTreeMap::new()); + let readiness = agent_readiness(&env); + assert!( + !readiness.is_ready(), + "unknown/custom command absent from PATH should be NotReady" + ); + let reqs = readiness.requirements(); + assert_eq!(reqs.len(), 1); + assert!( + matches!(&reqs[0], Requirement::MissingBinary { command } if command == "my-custom-harness-that-does-not-exist"), + "should surface MissingBinary requirement" + ); +} + +// ── 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 ───────────────────────────────────────── + +/// Minimal `ManagedAgentRecord` carrying only the fields +/// `resolve_effective_agent_env` reads, with caller-supplied `env_vars`. +fn minimal_record( + env_vars: BTreeMap, +) -> crate::managed_agents::types::ManagedAgentRecord { + 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_policy_pending: false, + 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, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + } +} + +#[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(), + ); + + let record = minimal_record(env_vars); + 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") + ); +} + +// ── openai-compat plain-reply delivery default ───────────────────────── +// +// Self-hosted OpenAI-compatible endpoints (llama.cpp, vLLM, a directly +// configured mesh-llm instance — the "Local LLM" case, distinct from the +// built-in relay-mesh preset) hit the same silent-drop bug relay-mesh's +// fallback exists for: a small local model answers in prose and never +// calls `send_message`. These tests lock in that this provider opts into +// the fallback by default, that the real OpenAI cloud API does not, and +// that an explicit user override always wins. + +#[test] +fn openai_compat_provider_opts_into_plain_reply_delivery() { + // The normalized provider dropdown persists to `record.provider` + // (what `resolve_effective_model_provider` actually reads), not to a + // raw `BUZZ_AGENT_PROVIDER` env var — that env var is itself derived + // from this typed field by an earlier env layer in real usage. + let mut env_vars = BTreeMap::new(); + env_vars.insert( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://llm1.example.ts.net:37073/v1".to_string(), + ); + let mut record = minimal_record(env_vars); + record.provider = Some("openai-compat".to_string()); + + let runtime = known_acp_runtime_exact("buzz-agent"); + let effective = resolve_effective_agent_env(&record, &[], runtime, &Default::default()); + + assert_eq!( + effective + .env + .get("BUZZ_ACP_DELIVER_PLAIN_REPLIES") + .map(String::as_str), + Some("true"), + "openai-compat should default to delivering plain-text replies" + ); +} + +#[test] +fn real_openai_provider_does_not_opt_into_plain_reply_delivery() { + // Plain "openai" (the real OpenAI cloud API) must NOT get the + // fallback: cloud models reliably call `send_message` themselves, and + // enabling it there risks double-posting. + let mut record = minimal_record(BTreeMap::new()); + record.provider = Some("openai".to_string()); + + let runtime = known_acp_runtime_exact("buzz-agent"); + let effective = resolve_effective_agent_env(&record, &[], runtime, &Default::default()); + + assert_eq!( + effective.env.get("BUZZ_ACP_DELIVER_PLAIN_REPLIES"), + None, + "real OpenAI cloud provider must not opt into plain-reply delivery" + ); +} + +#[test] +fn explicit_deliver_plain_replies_override_is_preserved_for_openai_compat() { + // A user (or a lower env layer) that explicitly disabled the fallback + // must not be silently overridden back to "true". + let mut env_vars = BTreeMap::new(); + env_vars.insert( + "BUZZ_ACP_DELIVER_PLAIN_REPLIES".to_string(), + "false".to_string(), + ); + let mut record = minimal_record(env_vars); + record.provider = Some("openai-compat".to_string()); + + let runtime = known_acp_runtime_exact("buzz-agent"); + let effective = resolve_effective_agent_env(&record, &[], runtime, &Default::default()); + + assert_eq!( + effective + .env + .get("BUZZ_ACP_DELIVER_PLAIN_REPLIES") + .map(String::as_str), + Some("false"), + "explicit user override must survive the openai-compat default" + ); +} + +#[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() + })); +} + +// ── OpenRouter readiness ───────────────────────────────────────────── + +#[test] +fn buzz_agent_openrouter_with_all_fields_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "openrouter with all fields should be ready" + ); +} + +#[test] +fn buzz_agent_openrouter_missing_key_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "OPENROUTER_API_KEY".to_string() + })); +} + +#[test] +fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), + ); + let result = agent_readiness(&env); + assert!( + result.is_ready(), + "OPENROUTER_MODEL fallback should satisfy model requirement" + ); +} 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 00000000000..797106c455d --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness_tests.rs @@ -0,0 +1,710 @@ +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: &[], + cli_install_instructions_url: "", + adapter_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, + max_rounds_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: absolute path so `find_command` resolves +/// it via `path.exists()`. Leaked allocation is intentional — process exits after tests. +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: &[], + cli_install_instructions_url: "", + adapter_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, + max_rounds_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]); + } +} diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index 3858212bbba..13433170462 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -13,6 +13,8 @@ pub const RELAY_MESH_AUTO_MODEL_ID: &str = "auto"; /// stored `auto` here rather than teaching buzz-agent anything about meshes. #[cfg(feature = "mesh-llm")] pub const RELAY_MESH_VIRTUAL_MODEL_ID: &str = "mesh"; +#[cfg(feature = "mesh-llm")] +pub const RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV: &str = "BUZZ_AGENT_PREFER_MESH_FOR_AUTO"; /// The wire name for a stored shared-compute model: `auto` (and a blank legacy /// value) means "let the mesh decide" and becomes MeshLLM's virtual `mesh` @@ -54,6 +56,24 @@ pub fn apply_relay_mesh_env( RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), ); env.insert("OPENAI_COMPAT_API".to_string(), "chat".to_string()); + // Buzz owns the meaning of relay-mesh `auto`: buzz-agent dynamically uses + // mesh-llm's virtual Mixture-of-Agents model whenever the live catalog says + // at least two distinct models are available, and otherwise keeps the + // router's normal single-model `auto` behavior. + env.insert( + RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV.to_string(), + "1".to_string(), + ); + // Shared compute runs small local models, which reliably finish a + // multi-step turn by writing the answer as prose instead of calling + // `send_message` — the reply is then never published. Opt this preset into + // the harness fallback that delivers that text. Mesh-only: cloud models + // publish their own replies, where the fallback would double-post. + // Value is `true`, not `1`: these are clap bool flags, which reject `1`. + env.insert( + "BUZZ_ACP_DELIVER_PLAIN_REPLIES".to_string(), + "true".to_string(), + ); // Keep the requested response inside smaller local-model context windows. // These are defaults, not policy: the effective agent/persona/global env // may deliberately choose a smaller cap or a different effort. This function @@ -140,6 +160,20 @@ mod tests { // stops gemma tool-calling; enabling thinking makes Qwen3 burn ~4x the // output budget). assert_eq!(env.get("BUZZ_AGENT_THINKING_EFFORT"), None); + assert_eq!( + env.get(RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV) + .map(String::as_str), + Some("1") + ); + // Small local models end multi-step turns with prose instead of a + // `send_message` call, so shared compute opts into harness delivery. + assert_eq!( + env.get("BUZZ_ACP_DELIVER_PLAIN_REPLIES") + .map(String::as_str), + // `true`, not `1`: clap bool flags reject `1` and the harness + // exits at startup with "invalid value '1'". + Some("true") + ); } /// Stored `auto` is translated here, so buzz-agent receives a plain model @@ -213,6 +247,16 @@ mod tests { ); } + #[test] + fn non_mesh_provider_does_not_opt_into_plain_reply_delivery() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env(&mut env, Some("anthropic"), Some("claude-sonnet-4")); + + // Cloud models publish their own replies; delivering streamed text + // there would double-post. + assert_eq!(env.get("BUZZ_ACP_DELIVER_PLAIN_REPLIES"), None); + } + #[test] fn native_provider_preserves_explicit_generation_controls() { let mut env = BTreeMap::from([