Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ const overrides = new Map([
// Windows-CI portability: replaced POSIX true/false probes with current_exe()
// stand-in + present_binary_str()/static_commands() helpers (+29 lines).
// Tests now pass on windows-latest CI shard without POSIX shell utilities.
["src-tauri/src/managed_agents/readiness.rs", 1403],
// databricks-v1-to-v2-migration: databricks-v2 hyphen-alias added to all
// host/credential match arms + 30+ readiness tests for provider aliases,
// missing-host, and DATABRICKS_MODEL fallback. Load-bearing correctness fix.
["src-tauri/src/managed_agents/readiness.rs", 1546],
// applyWorkspace reposDir parameter plus the validateReposDir binding,
// threaded through Tauri invokes for configurable repos_dir, plus the
// harness-persona-sync `harnessOverride` create-input bit — load-bearing
Expand Down Expand Up @@ -185,7 +188,11 @@ const overrides = new Map([
// the pre-identity data migrations; still queued to split further.
// unified-agent-model 1A.1: materialize_agent_runtimes split to
// migration/materialize.rs, ratcheting 1310 -> 1297.
["src-tauri/src/migration.rs", 1297],
// databricks-v1-to-v2-migration: reconcile_databricks_v1_to_v2 migration
// + inner fn with baked-env gate + 26 tests. Load-bearing correctness fix.
// am review fix: also clear stale V1 model field on provider rewrite +
// new model-clear test. Load-bearing chimera fix.
["src-tauri/src/migration.rs", 1402],
// onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop
// already here) for the single-toggle mark-read/unread menu item — a small
// overage from load-bearing per-message plumbing, not generic debt growth.
Expand Down
6 changes: 4 additions & 2 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -638,12 +638,14 @@ fn is_databricks_provider(provider: Option<&str>) -> bool {
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref(),
Some("databricks" | "databricks_v2")
Some("databricks" | "databricks_v2" | "databricks-v2")
)
}

fn databricks_agent_provider(provider: &str) -> buzz_agent_pkg::config::Provider {
if provider.trim().eq_ignore_ascii_case("databricks_v2") {
if provider.trim().eq_ignore_ascii_case("databricks_v2")
|| provider.trim().eq_ignore_ascii_case("databricks-v2")
{
buzz_agent_pkg::config::Provider::DatabricksV2
} else {
buzz_agent_pkg::config::Provider::Databricks
Expand Down
162 changes: 157 additions & 5 deletions desktop/src-tauri/src/managed_agents/readiness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,12 +276,29 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec<Requirement> {

// Model is required — maps to BUZZ_AGENT_MODEL in the effective env.
// Same empty-string treatment as provider.
let model = effective
// Also accept provider-specific model fallback keys, matching buzz-agent's
// own config.rs `from_env()` resolution order (e.g. DATABRICKS_MODEL for
// databricks/databricks_v2, ANTHROPIC_MODEL for anthropic, etc.). The
// baked buzz-releases env sets DATABRICKS_MODEL but not BUZZ_AGENT_MODEL,
// so without this fallback agents baked from releases appear "not ready".
let provider_model_key = match provider {
Some("databricks") | Some("databricks_v2") | Some("databricks-v2") => {
Some("DATABRICKS_MODEL")
}
Some("anthropic") => Some("ANTHROPIC_MODEL"),
Some("openai") | Some("openai-compat") => Some("OPENAI_COMPAT_MODEL"),
_ => None,
};
let model_present = effective
.env
.get("BUZZ_AGENT_MODEL")
.filter(|v| !v.is_empty())
.map(String::as_str);
if model.is_none() {
.is_some()
|| provider_model_key
.and_then(|k| effective.env.get(k))
.filter(|v| !v.is_empty())
.is_some();
if !model_present {
missing.push(Requirement::NormalizedField {
field: "model".to_string(),
});
Expand All @@ -304,7 +321,7 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec<Requirement> {
key: "OPENAI_COMPAT_API_KEY".to_string(),
});
}
Some("databricks") | Some("databricks_v2")
Some("databricks") | Some("databricks_v2") | Some("databricks-v2")
// DATABRICKS_HOST is hard-required; DATABRICKS_TOKEN is optional
// (OAuth PKCE is the normal path — see buzz-agent/src/config.rs:143).
if env_key_missing("DATABRICKS_HOST") => {
Expand Down Expand Up @@ -412,7 +429,7 @@ fn goose_requirements(
key: "OPENAI_COMPAT_API_KEY".to_string(),
});
}
Some("databricks") | Some("databricks_v2")
Some("databricks") | Some("databricks_v2") | Some("databricks-v2")
if env_key_missing("DATABRICKS_HOST") && !file_key_present("DATABRICKS_HOST") =>
{
missing.push(Requirement::EnvKey {
Expand Down Expand Up @@ -1199,6 +1216,141 @@ mod tests {
Some("claude-opus-4-5")
);
}

// ── provider-specific model fallback tests ────────────────────────────

#[test]
fn buzz_agent_databricks_v2_with_databricks_model_but_no_buzz_agent_model_is_ready() {
// The baked buzz-releases env sets DATABRICKS_MODEL but not BUZZ_AGENT_MODEL.
// An agent with only DATABRICKS_MODEL must pass the readiness gate.
let env = make_env(
"buzz-agent",
env_with(&[
("BUZZ_AGENT_PROVIDER", "databricks_v2"),
("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"),
("DATABRICKS_HOST", "https://dbc.example.com"),
]),
);
assert!(
agent_readiness(&env).is_ready(),
"DATABRICKS_MODEL must satisfy the model requirement for databricks_v2"
);
}

#[test]
fn buzz_agent_databricks_v2_hyphen_alias_with_databricks_model_is_ready() {
// buzz-agent accepts both "databricks_v2" and "databricks-v2". The
// readiness gate must recognize the hyphen alias and accept DATABRICKS_MODEL.
let env = make_env(
"buzz-agent",
env_with(&[
("BUZZ_AGENT_PROVIDER", "databricks-v2"),
("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"),
("DATABRICKS_HOST", "https://dbc.example.com"),
]),
);
assert!(
agent_readiness(&env).is_ready(),
"databricks-v2 alias with DATABRICKS_MODEL must be Ready"
);
}

#[test]
fn buzz_agent_databricks_hyphen_alias_missing_host_returns_not_ready() {
// The hyphen alias "databricks-v2" requires DATABRICKS_HOST just like
// the underscore variants. Without it the agent cannot reach the endpoint.
let env = make_env(
"buzz-agent",
env_with(&[
("BUZZ_AGENT_PROVIDER", "databricks-v2"),
("DATABRICKS_MODEL", "goose-claude-4-6-sonnet"),
// DATABRICKS_HOST intentionally absent
]),
);
let result = agent_readiness(&env);
assert!(
!result.is_ready(),
"databricks-v2 without DATABRICKS_HOST must be NotReady"
);
let reqs = result.requirements();
assert!(
reqs.iter()
.any(|r| matches!(r, Requirement::EnvKey { key } if key == "DATABRICKS_HOST")),
"missing requirements must include DATABRICKS_HOST; got {reqs:?}"
);
}

#[test]
fn buzz_agent_databricks_v1_with_databricks_model_but_no_buzz_agent_model_is_ready() {
// V1 (Model Serving) also resolves DATABRICKS_MODEL — same fallback applies.
let env = make_env(
"buzz-agent",
env_with(&[
("BUZZ_AGENT_PROVIDER", "databricks"),
("DATABRICKS_MODEL", "dbrx-instruct"),
("DATABRICKS_HOST", "https://dbc.example.com"),
]),
);
assert!(
agent_readiness(&env).is_ready(),
"DATABRICKS_MODEL must satisfy the model requirement for databricks (V1)"
);
}

#[test]
fn buzz_agent_anthropic_with_anthropic_model_but_no_buzz_agent_model_is_ready() {
let env = make_env(
"buzz-agent",
env_with(&[
("BUZZ_AGENT_PROVIDER", "anthropic"),
("ANTHROPIC_MODEL", "claude-opus-4-5"),
("ANTHROPIC_API_KEY", "sk-test"),
]),
);
assert!(
agent_readiness(&env).is_ready(),
"ANTHROPIC_MODEL must satisfy the model requirement for anthropic"
);
}

#[test]
fn buzz_agent_openai_with_openai_compat_model_but_no_buzz_agent_model_is_ready() {
let env = make_env(
"buzz-agent",
env_with(&[
("BUZZ_AGENT_PROVIDER", "openai"),
("OPENAI_COMPAT_MODEL", "gpt-4o"),
("OPENAI_COMPAT_API_KEY", "sk-test"),
]),
);
assert!(
agent_readiness(&env).is_ready(),
"OPENAI_COMPAT_MODEL must satisfy the model requirement for openai"
);
}

#[test]
fn buzz_agent_empty_provider_model_fallback_key_is_not_ready() {
// An empty DATABRICKS_MODEL with no BUZZ_AGENT_MODEL must still be NotReady.
let env = make_env(
"buzz-agent",
env_with(&[
("BUZZ_AGENT_PROVIDER", "databricks_v2"),
("DATABRICKS_MODEL", ""),
("DATABRICKS_HOST", "https://dbc.example.com"),
]),
);
let result = agent_readiness(&env);
assert!(
!result.is_ready(),
"empty DATABRICKS_MODEL with no BUZZ_AGENT_MODEL must be NotReady"
);
assert!(result
.requirements()
.contains(&Requirement::NormalizedField {
field: "model".to_string()
}));
}
}

// ── goose file-config–aware requirement tests ─────────────────────────────
Expand Down
113 changes: 113 additions & 0 deletions desktop/src-tauri/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ pub fn run_boot_migrations(app: &tauri::AppHandle) {
eprintln!("buzz-desktop: sync-team-personas: {e}");
}
reconcile_provider_mcp_commands(app);
reconcile_databricks_v1_to_v2(app);
materialize_agent_runtimes(app);
}

Expand Down Expand Up @@ -1234,6 +1235,114 @@ pub fn reconcile_provider_mcp_commands(app: &tauri::AppHandle) {
}
}

fn reconcile_databricks_v1_to_v2_in_file(path: &Path, rewrite_v1_provider: bool) {
use crate::managed_agents::is_derived_provider_model_key;
patch_json_records(path, |obj| {
let mut changed = false;

// Only rewrite the structured provider field when the baked build env
// marks this as a Block build (BUZZ_AGENT_PROVIDER == "databricks_v2").
// OSS users may intentionally select V1 (Model Serving), so we must not
// silently migrate their provider to V2 (AI Gateway).
if rewrite_v1_provider && obj.get("provider").and_then(|v| v.as_str()) == Some("databricks")
{
let name = obj
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
eprintln!(
"buzz-desktop: databricks-v1-to-v2: {name:?}: provider \"databricks\" → \"databricks_v2\"",
);
obj.insert(
"provider".to_string(),
serde_json::Value::String("databricks_v2".to_string()),
);
// Also clear the model field — a V1 model name (e.g. "dbrx-instruct")
// on a V2 provider would shadow the baked DATABRICKS_MODEL at spawn time
// (BUZZ_AGENT_MODEL from runtime_metadata_env_vars takes priority in
// buzz-agent config.rs). Clearing it lets the baked V2 default win.
if obj.remove("model").is_some() {
eprintln!(
"buzz-desktop: databricks-v1-to-v2: {name:?}: cleared stale V1 model field",
);
}
changed = true;
}

// Strip derived provider/model keys from env_vars on ALL records,
// regardless of rewrite_v1_provider. These keys are re-derived from
// structured fields at spawn time; stale copies in env_vars silently
// override the structured fields (last-write-wins in Command::env) and
// can cause V1 routing even when the provider dropdown shows V2.
//
// The check is case-insensitive (matching the established helper)
// to cover any case-variant that may have been written historically.
if let Some(serde_json::Value::Object(env_vars)) = obj.get_mut("env_vars") {
let stale_keys: Vec<String> = env_vars
.keys()
.filter(|k| is_derived_provider_model_key(k))
.cloned()
.collect();
for key in stale_keys {
env_vars.remove(key.as_str());
eprintln!("buzz-desktop: databricks-v1-to-v2: removed stale env_vars[\"{key}\"]",);
changed = true;
}
}

changed
});
}

/// Strip stale derived provider/model keys from `env_vars` in all
/// managed-agent records, and — on Block builds — also migrate any persisted
/// `provider: "databricks"` to `"databricks_v2"`.
///
/// **Block builds** (where `baked_build_env()` contains
/// `BUZZ_AGENT_PROVIDER=databricks_v2`): the structured `provider` field is
/// rewritten V1→V2 because the baked release targets V2 exclusively. Records
/// that were saved before this migration would otherwise silently override the
/// baked value at spawn time (last-write-wins in `Command::env`).
///
/// **OSS builds** (baked env empty): the `provider` field is left alone —
/// V1 (`databricks`) is a valid Model Serving choice for OSS users.
///
/// In both cases, stale `BUZZ_AGENT_PROVIDER` / `BUZZ_AGENT_MODEL` /
/// `GOOSE_PROVIDER` / `GOOSE_MODEL` are stripped from `env_vars`. These keys
/// are always re-derived from structured fields at spawn time; persisted copies
/// silence UI edits and cause stale routing.
///
/// Covers both the current app data dir and the canonical dev data dir
/// (for worktree instances) — same dual-dir pattern as
/// `reconcile_legacy_command_names` and `reconcile_provider_mcp_commands`.
pub fn reconcile_databricks_v1_to_v2(app: &tauri::AppHandle) {
use crate::managed_agents::baked_build_env;
// On Block builds, the baked env contains BUZZ_AGENT_PROVIDER=databricks_v2.
// Use that as a reliable signal that this is a Block build and the V1
// provider should be migrated. OSS builds have an empty baked env, so
// rewrite_v1_provider is false and the structured provider is preserved.
let rewrite_v1_provider = baked_build_env()
.get("BUZZ_AGENT_PROVIDER")
.map(|v| v == "databricks_v2")
.unwrap_or(false);
let Ok(current_dir) = app.path().app_data_dir() else {
return;
};
let mut dirs = vec![current_dir.clone()];
if let Some(canonical) = canonical_dev_data_dir(&current_dir) {
if canonical.exists() && canonical != current_dir {
dirs.push(canonical);
}
}
for dir in dirs {
let path = dir.join("agents/managed-agents.json");
if path.exists() {
reconcile_databricks_v1_to_v2_in_file(&path, rewrite_v1_provider);
}
}
}

fn rename_provider_to_runtime_in_personas(path: &Path) {
patch_json_records(path, |obj| {
if obj.contains_key("runtime") {
Expand Down Expand Up @@ -1279,6 +1388,10 @@ mod tests;
#[path = "migration_command_tests.rs"]
mod command_tests;

#[cfg(test)]
#[path = "migration_databricks_tests.rs"]
mod databricks_tests;

#[cfg(test)]
#[path = "migration_team_dir_tests.rs"]
mod team_dir_tests;
Expand Down
Loading
Loading