fix(agents): B-collapse same-value live effort + thought_level ACP identity + alias normalization (Phase 1)

- EffortNormalization struct in runtime_metadata.rs with canonical values,
  aliases, and normalize_str(); GOOSE_EFFORT_NORMALIZATION static cited to
  goose thinking.rs:277-308 (canonical: off/low/medium/high/max; aliases:
  none|disabled→off, med→medium, xhigh→max)
- effort_normalization field on KnownAcpRuntime; accepted_effort_values on
  AcpRuntimeCatalogEntry (serialized to TS as the single canonical authority)
- LEGACY_THINKING_EFFORT_KEY and ALL_KNOWN_EFFORT_KEYS constants in
  config_bridge/mod.rs; effort_tier_alias() for per-tier native+legacy key
  resolution with normalization and global-tier exclusion
- find_effort_config_option() identifies live effort by ACP category
  thought_level (Goose/claude-agent-acp); falls back to legacy effort
  category for transition compatibility; thought_level always wins when both
  present; returns (value, config_id) so write-route uses the real entry id
- build_thinking_field() normalizes all candidates before precedence,
  override tracking, and B-equality; B-collapse compares normalized ACP
  value against normalized non-ACP winner — none↔off and xhigh↔max collapse
  correctly; write_via uses actual matched config_id (not hardcoded effort)
- Test hygiene: test_runtime()/buzz_agent_runtime() use catalog lookups;
  6 goose_runtime() tests wrapped with with_no_goose_config(); 4 readiness
  stubs gain effort_normalization: None
- 6 new tests in reader_tests_ext.rs pin real Goose ACP shape, both-present
  priority, legacy fallback, alias B-collapse (none↔off, xhigh↔max), and
  env alias normalization

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-08-03 19:57:39 -04:00
parent cb4a73e17d
commit 66d77dc535
11 changed files with 887 additions and 183 deletions

View File

@@ -29,39 +29,7 @@ fn with_no_goose_config<T>(body: impl FnOnce() -> T) -> T {
}
fn goose_runtime() -> &'static KnownAcpRuntime {
&KnownAcpRuntime {
id: "goose",
label: "Goose",
commands: &["goose"],
aliases: &[],
avatar_url: "",
mcp_command: None,
mcp_hooks: false,
underlying_cli: None,
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,
model_env_var: Some("GOOSE_MODEL"),
provider_env_var: Some("GOOSE_PROVIDER"),
provider_locked: false,
default_env: &[],
config_file_path: Some("~/.config/goose/config.yaml"),
config_file_format: Some("yaml"),
supports_acp_native_config: true,
thinking_env_var: Some("GOOSE_THINKING_EFFORT"),
max_tokens_env_var: Some("GOOSE_MAX_TOKENS"),
context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"),
max_rounds_env_var: None,
required_normalized_fields: &["model", "provider"],
login_hint: None,
auth_probe_args: None,
}
crate::managed_agents::known_acp_runtime_exact("goose").expect("goose must be in catalog")
}
fn agent_record() -> ManagedAgentRecord {
@@ -175,13 +143,15 @@ fn linked_stale_record_model_never_outranks_persona_model() {
record.model = Some("stale-explicit-model".to_string());
let personas = vec![persona_with_model("persona-model")];
let surface = resolve_config_surface(
record,
&personas,
Some(goose_runtime()),
None,
&Default::default(),
);
let surface = with_no_goose_config(|| {
resolve_config_surface(
record,
&personas,
Some(goose_runtime()),
None,
&Default::default(),
)
});
let model = surface.normalized.model.as_ref().expect("model resolved");
assert_eq!(model.value.as_deref(), Some("persona-model"));
@@ -205,7 +175,9 @@ fn linked_blank_definition_model_falls_through_to_global_default() {
..Default::default()
};
let surface = resolve_config_surface(record, &personas, Some(goose_runtime()), None, &global);
let surface = with_no_goose_config(|| {
resolve_config_surface(record, &personas, Some(goose_runtime()), None, &global)
});
let model = surface.normalized.model.as_ref().expect("model resolved");
assert_eq!(model.value.as_deref(), Some("global-model"));
@@ -222,13 +194,15 @@ fn definition_less_explicit_record_model_keeps_buzz_explicit_origin() {
record.model = Some("explicit-model".to_string());
let personas = vec![persona_with_model("persona-model")];
let surface = resolve_config_surface(
record,
&personas,
Some(goose_runtime()),
None,
&Default::default(),
);
let surface = with_no_goose_config(|| {
resolve_config_surface(
record,
&personas,
Some(goose_runtime()),
None,
&Default::default(),
)
});
let model = surface.normalized.model.as_ref().expect("model resolved");
assert_eq!(model.value.as_deref(), Some("explicit-model"));
@@ -249,13 +223,15 @@ fn pending_pick_keeps_explicit_x_and_does_not_surface_live_y() {
let personas: Vec<AgentDefinition> = vec![];
let cache = session_cache("model-y", false);
let surface = resolve_config_surface(
record,
&personas,
Some(goose_runtime()),
Some(&cache),
&Default::default(),
);
let surface = with_no_goose_config(|| {
resolve_config_surface(
record,
&personas,
Some(goose_runtime()),
Some(&cache),
&Default::default(),
)
});
let model = surface.normalized.model.expect("model resolved");
assert_eq!(model.value.as_deref(), Some("model-x"));
@@ -277,13 +253,15 @@ fn genuine_explicit_live_switch_renders_y_over_x_buzz_explicit_secondary() {
let personas: Vec<AgentDefinition> = vec![];
let cache = session_cache("model-y", true);
let surface = resolve_config_surface(
record,
&personas,
Some(goose_runtime()),
Some(&cache),
&Default::default(),
);
let surface = with_no_goose_config(|| {
resolve_config_surface(
record,
&personas,
Some(goose_runtime()),
Some(&cache),
&Default::default(),
)
});
let model = surface.normalized.model.expect("model resolved");
assert_eq!(model.value.as_deref(), Some("model-y"));
@@ -340,13 +318,15 @@ fn persona_linked_live_switch_keeps_persona_default_secondary() {
let personas = vec![persona_with_model("persona-model")];
let cache = session_cache("model-y", true);
let surface = resolve_config_surface(
record,
&personas,
Some(goose_runtime()),
Some(&cache),
&Default::default(),
);
let surface = with_no_goose_config(|| {
resolve_config_surface(
record,
&personas,
Some(goose_runtime()),
Some(&cache),
&Default::default(),
)
});
let model = surface.normalized.model.expect("model resolved");
assert_eq!(model.value.as_deref(), Some("model-y"));
@@ -375,13 +355,15 @@ fn global_default_live_switch_renders_global_model_as_secondary_global_default()
..Default::default()
};
let surface = resolve_config_surface(
record,
&personas,
Some(goose_runtime()),
Some(&cache),
&global,
);
let surface = with_no_goose_config(|| {
resolve_config_surface(
record,
&personas,
Some(goose_runtime()),
Some(&cache),
&global,
)
});
let model = surface.normalized.model.expect("model resolved");
// Live model wins as primary.

View File

@@ -45,8 +45,7 @@ pub(crate) fn plan_adapter_install<'c>(
{
None
}
// Codex adapter is outdated: uninstall the old package first so npm
// doesn't hit EEXIST on the shared `codex-acp` bin-link, then install.
// Codex adapter outdated: uninstall old package first (npm refuses to overwrite a bin from another pkg).
Some(_) => Some(vec![
"npm uninstall -g @zed-industries/codex-acp",
"npm install -g @agentclientprotocol/codex-acp",
@@ -165,6 +164,7 @@ pub async fn save_custom_harness(
model_env_var: None,
provider_env_var: None,
thinking_env_var: None,
accepted_effort_values: None,
max_tokens_env_var: None,
context_limit_env_var: None,
max_rounds_env_var: None,

View File

@@ -8,6 +8,63 @@ pub(crate) mod types;
pub(crate) use types::*;
/// The legacy effort env key written by pre-migration saves.
///
/// Harnesses whose native `thinking_env_var` differs from this constant
/// (currently: Goose uses `GOOSE_THINKING_EFFORT`) need the alias resolver
/// below to translate old saves. buzz-agent's native key equals this constant,
/// so no aliasing applies there.
pub(crate) const LEGACY_THINKING_EFFORT_KEY: &str = "BUZZ_AGENT_THINKING_EFFORT";
/// The set of all known native thinking-effort env keys across all runtimes.
/// Used to strip foreign effort keys from a runtime's effective descriptor.
/// Must stay in sync with `KnownAcpRuntime::thinking_env_var` declarations.
#[allow(dead_code)] // used in Phase 3 (spawn foreign-key stripping in readiness.rs)
pub(crate) const ALL_KNOWN_EFFORT_KEYS: &[&str] = &[
LEGACY_THINKING_EFFORT_KEY, // buzz-agent native
"GOOSE_THINKING_EFFORT", // Goose native
];
/// Resolve the thinking-effort value for a single env-var tier map, with
/// within-tier legacy aliasing and normalization.
///
/// Returns the **canonical** value (normalized via `norm`) for the tier, or
/// `None` when no usable candidate exists.
///
/// Lookup order (applied independently per tier, not globally):
/// 1. Native key (`native_key`) — value normalized; invalid values skip as absent.
/// 2. Legacy key (`BUZZ_AGENT_THINKING_EFFORT`) — honoured only when:
/// (a) `native_key` differs from the legacy key (i.e. non-buzz-agent runtime), AND
/// (b) `global_tier` is false (legacy alias excluded from global tier), AND
/// (c) the value normalizes to a canonical form.
/// An invalid legacy value is skipped so the next tier can supply a candidate.
///
/// The `norm` function normalizes a raw value to canonical form; `None` = invalid.
pub(crate) fn effort_tier_alias(
map: &std::collections::BTreeMap<String, String>,
native_key: &str,
norm: impl Fn(&str) -> Option<String>,
global_tier: bool,
) -> Option<String> {
// Native key first — normalize the value; invalid → skip.
if let Some(raw) = map.get(native_key) {
if let Some(canonical) = norm(raw) {
return Some(canonical);
}
// Invalid native value: skip-as-absent, fall through to legacy.
}
// Legacy alias — only when keys differ and this is not the global tier.
if !global_tier && native_key != LEGACY_THINKING_EFFORT_KEY {
if let Some(raw) = map.get(LEGACY_THINKING_EFFORT_KEY) {
if let Some(canonical) = norm(raw) {
return Some(canonical);
}
// Invalid legacy value for this harness: skip, fall through to next tier.
}
}
None
}
/// Read the goose harness config file (`~/.config/goose/config.yaml`).
///
/// Used by readiness evaluation to silence requirements that are already

View File

@@ -1,4 +1,5 @@
use crate::managed_agents::discovery::KnownAcpRuntime;
use crate::managed_agents::config_bridge::effort_tier_alias;
use crate::managed_agents::discovery::{EffortNormalization, KnownAcpRuntime};
use crate::managed_agents::types::ManagedAgentRecord;
use super::types::*;
@@ -35,6 +36,7 @@ pub(crate) fn read_config_surface(
let provider_env_var = runtime_meta.and_then(|m| m.provider_env_var);
let provider_locked = runtime_meta.is_some_and(|m| m.provider_locked);
let thinking_env_var = runtime_meta.and_then(|m| m.thinking_env_var);
let effort_norm = runtime_meta.and_then(|m| m.effort_normalization);
let supports_acp_native = runtime_meta.is_some_and(|m| m.supports_acp_native_config);
let required_fields: &[&str] = runtime_meta
.map(|m| m.required_normalized_fields)
@@ -49,7 +51,12 @@ pub(crate) fn read_config_surface(
.or_else(|| find_config_option_value(c, "model"))
});
let acp_mode = session_cache.and_then(|c| find_config_option_value(c, "mode"));
let acp_effort = session_cache.and_then(|c| find_config_option_value(c, "effort"));
// Effort: find by real ACP category `thought_level` (Goose, claude-agent-acp); fall back to
// the legacy invented category `effort` for transition compatibility. Retain the matched
// entry's actual config_id for write-back routing.
let acp_effort_opt = session_cache.and_then(find_effort_config_option);
let acp_effort = acp_effort_opt.as_ref().map(|(v, _)| v.clone());
let acp_effort_config_id = acp_effort_opt.map(|(_, id)| id);
let model_overridden = session_cache.is_some_and(|c| c.model_overridden);
@@ -79,7 +86,9 @@ pub(crate) fn read_config_surface(
record,
&file_config.thinking_effort,
&acp_effort,
acp_effort_config_id.as_deref(),
thinking_env_var,
effort_norm,
is_pre_spawn,
session_cache,
tiers,
@@ -486,37 +495,164 @@ fn build_thinking_field(
record: &ManagedAgentRecord,
file_effort: &Option<String>,
acp_effort: &Option<String>,
acp_effort_config_id: Option<&str>,
thinking_env_var: Option<&str>,
effort_norm: Option<&'static EffortNormalization>,
is_pre_spawn: bool,
session_cache: Option<&SessionConfigCache>,
_session_cache: Option<&SessionConfigCache>,
tiers: &InheritedConfigTiers,
) -> Option<NormalizedField> {
// Tier ordering: record env > ACP > persona env > global env > definition env > config file.
let [rec_env, pers_env, glob_env, def_env] = thinking_env_var
.map(|k| {
env_candidates(
k,
&record.env_vars,
&tiers.persona_env,
&tiers.global_env,
&tiers.definition_env,
)
})
.unwrap_or([None, None, None, None]);
//
// When the runtime declares an effort normalization contract (e.g. Goose), each of the
// record and persona tiers is resolved via `effort_tier_alias`, which applies within-tier
// legacy aliasing with normalization: if the native key is absent or invalid, fall back to
// BUZZ_AGENT_THINKING_EFFORT when the value normalizes to a canonical form.
// Global env uses the same resolver but with `global_tier=true` — legacy alias excluded.
// Definition env also uses `global_tier=true` semantics (no legacy alias there).
let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[
// Resolve per-tier effort values with optional normalization.
// Owned values kept in locals to outlive the &str borrows below.
let _rec_o: Option<String>;
let _per_o: Option<String>;
let _glo_o: Option<String>;
let _def_o: Option<String>;
let _file_o: Option<String>;
let rec_env: Option<&str>;
let pers_env: Option<&str>;
let glob_env: Option<&str>;
let def_env: Option<&str>;
let file_env: Option<&str>;
if let Some((native, norm)) = thinking_env_var.zip(effort_norm) {
let nf = |v: &str| norm.normalize_str(v);
_rec_o = effort_tier_alias(&record.env_vars, native, nf, false);
_per_o = effort_tier_alias(&tiers.persona_env, native, nf, false);
_glo_o = effort_tier_alias(&tiers.global_env, native, nf, true);
_def_o = effort_tier_alias(&tiers.definition_env, native, nf, true);
// Normalize file effort too for consistent B comparison.
_file_o = file_effort.as_deref().and_then(|v| norm.normalize_str(v));
rec_env = _rec_o.as_deref();
pers_env = _per_o.as_deref();
glob_env = _glo_o.as_deref();
def_env = _def_o.as_deref();
file_env = _file_o.as_deref();
} else {
_rec_o = None;
_per_o = None;
_glo_o = None;
_def_o = None;
_file_o = None;
let [re, pe, ge, de] = thinking_env_var
.map(|k| {
env_candidates(
k,
&record.env_vars,
&tiers.persona_env,
&tiers.global_env,
&tiers.definition_env,
)
})
.unwrap_or([None, None, None, None]);
rec_env = re;
pers_env = pe;
glob_env = ge;
def_env = de;
file_env = file_effort.as_deref();
}
// Effort same-value collapse (B heuristic): when ACP would be the winner and its value
// exactly equals what inheritance already resolves to (after normalization), fall through to
// non-ACP resolution so the panel shows the true baseline origin ("Global default") rather
// than "Runtime override (this session only)". The live session is almost certainly echoing
// what spawn injected.
//
// Effort-only: model has an explicit `model_overridden` signal; effort has no equivalent,
// so we apply the heuristic unconditionally for effort alone (plan v3 Phase 1).
let without_acp: &[(Option<&str>, ConfigOrigin)] = &[
(rec_env, ConfigOrigin::BuzzExplicit),
(acp_effort.as_deref(), ConfigOrigin::AcpConfigOption),
(pers_env, ConfigOrigin::PersonaDefault),
(glob_env, ConfigOrigin::GlobalDefault),
(def_env, ConfigOrigin::HarnessDefault),
(file_effort.as_deref(), ConfigOrigin::ConfigFile),
(file_env, ConfigOrigin::ConfigFile),
];
// Normalize the raw ACP effort value (plan v3 Delta 1: every candidate normalized
// before validity, precedence, override tracking, and B equality).
// When the runtime declares a normalization contract (`effort_norm`), canonicalize
// the live ACP value; if it is an alias (`none`→`off`, `xhigh`→`max`, case-fold)
// the normalized form is used for comparison and display. If the ACP value is
// invalid for this runtime (e.g. `minimal` or garbage), treat it as absent so
// lower tiers win — skip-as-absent applies to the output map, not just winner
// selection. The matched `config_id` is preserved for `write_via` regardless.
let _acp_norm_o: Option<String>;
let acp_effort_normalized: Option<&str> =
if let (Some(raw), Some(norm)) = (acp_effort.as_deref(), effort_norm) {
match norm.normalize_str(raw) {
Some(canonical) => {
_acp_norm_o = Some(canonical.to_string());
_acp_norm_o.as_deref()
}
None => {
// ACP value is invalid for this runtime — skip as absent.
_acp_norm_o = None;
None
}
}
} else {
// No normalization contract (buzz-agent legacy path): pass through raw.
_acp_norm_o = None;
acp_effort.as_deref()
};
let tiers_list: &[(Option<&str>, ConfigOrigin)];
let with_acp_storage;
if rec_env.is_none() {
if let Some(acp) = acp_effort_normalized {
let baseline_value = without_acp
.iter()
.find(|(v, _)| v.is_some())
.and_then(|(v, _)| *v);
if baseline_value == Some(acp) {
// Equal-value (after normalization): fall through to non-ACP resolution.
tiers_list = without_acp;
} else {
// Genuine divergence: ACP wins between rec_env and pers_env.
with_acp_storage = [
(rec_env, ConfigOrigin::BuzzExplicit),
(Some(acp), ConfigOrigin::AcpConfigOption),
(pers_env, ConfigOrigin::PersonaDefault),
(glob_env, ConfigOrigin::GlobalDefault),
(def_env, ConfigOrigin::HarnessDefault),
(file_env, ConfigOrigin::ConfigFile),
];
tiers_list = &with_acp_storage;
}
} else {
tiers_list = without_acp;
}
} else {
// rec_env is Some: record-level env always wins over ACP regardless.
// Insert normalized ACP (or None if invalid) to preserve tier structure.
with_acp_storage = [
(rec_env, ConfigOrigin::BuzzExplicit),
(acp_effort_normalized, ConfigOrigin::AcpConfigOption),
(pers_env, ConfigOrigin::PersonaDefault),
(glob_env, ConfigOrigin::GlobalDefault),
(def_env, ConfigOrigin::HarnessDefault),
(file_env, ConfigOrigin::ConfigFile),
];
tiers_list = &with_acp_storage;
}
let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers_list)?;
let write_via = if !is_pre_spawn && has_config_option(session_cache, "effort") {
// Write route: use the matched entry's actual config_id (not a hardcoded constant).
// `thought_level` category entries advertise `thinking_effort` as their id (Goose);
// claude-agent-acp uses its own id. Never hardcode `"effort"`.
let write_via = if let (false, Some(config_id)) = (is_pre_spawn, acp_effort_config_id) {
ConfigWriteMechanism::AcpSetConfigOption {
config_id: "effort".to_string(),
config_id: config_id.to_string(),
}
} else if let Some(env_key) = thinking_env_var {
ConfigWriteMechanism::RespawnWithEnvVar {
@@ -694,6 +830,37 @@ fn find_model_config_id(cache: Option<&SessionConfigCache>) -> Option<String> {
})
}
/// Find the live effort ACP option, returning `(current_value, config_id)`.
///
/// Searches by ACP category `thought_level` first — the schema constant used by
/// Goose (`acp/response_builder.rs:286-323`) and claude-agent-acp. Falls back to
/// the legacy invented category `effort` for transition compatibility with old
/// test fixtures and adapters that were built before the canonical category name
/// was established. `thought_level` always wins when both categories are present.
fn find_effort_config_option(cache: &SessionConfigCache) -> Option<(String, String)> {
// Primary: real ACP category emitted by Goose and claude-agent-acp.
if let Some(entry) = cache
.config_options
.iter()
.find(|e| e.category.as_deref() == Some("thought_level"))
{
if let Some(v) = &entry.current_value {
return Some((v.clone(), entry.config_id.clone()));
}
}
// Fallback: legacy invented category (transition / test fixtures).
if let Some(entry) = cache
.config_options
.iter()
.find(|e| e.category.as_deref() == Some("effort"))
{
if let Some(v) = &entry.current_value {
return Some((v.clone(), entry.config_id.clone()));
}
}
None
}
fn resolve_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {

View File

@@ -28,39 +28,7 @@ fn with_goose_path_root<T>(value: Option<&str>, body: impl FnOnce() -> T) -> T {
}
fn test_runtime() -> &'static KnownAcpRuntime {
&KnownAcpRuntime {
id: "goose",
label: "Goose",
commands: &["goose"],
aliases: &[],
avatar_url: "",
mcp_command: None,
mcp_hooks: false,
underlying_cli: None,
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,
model_env_var: Some("GOOSE_MODEL"),
provider_env_var: Some("GOOSE_PROVIDER"),
provider_locked: false,
default_env: &[],
config_file_path: Some("~/.config/goose/config.yaml"),
config_file_format: Some("yaml"),
supports_acp_native_config: true,
thinking_env_var: Some("GOOSE_THINKING_EFFORT"),
max_tokens_env_var: Some("GOOSE_MAX_TOKENS"),
context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"),
max_rounds_env_var: None,
required_normalized_fields: &["model", "provider"],
login_hint: None,
auth_probe_args: None,
}
crate::managed_agents::known_acp_runtime_exact("goose").expect("goose must be in catalog")
}
fn test_record() -> ManagedAgentRecord {
@@ -617,39 +585,8 @@ fn extra_env_var_skipped_when_already_in_file_config_extra() {
// them in the advanced tier.
fn buzz_agent_runtime() -> &'static KnownAcpRuntime {
&KnownAcpRuntime {
id: "buzz-agent",
label: "Buzz Agent",
commands: &["buzz-agent"],
aliases: &[],
avatar_url: "",
mcp_command: None,
mcp_hooks: false,
underlying_cli: None,
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: true,
model_env_var: Some("BUZZ_AGENT_MODEL"),
provider_env_var: Some("BUZZ_AGENT_PROVIDER"),
provider_locked: false,
default_env: &[],
config_file_path: None,
config_file_format: None,
supports_acp_native_config: false,
thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"),
max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"),
context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"),
max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"),
required_normalized_fields: &["model", "provider"],
login_hint: None,
auth_probe_args: None,
}
crate::managed_agents::known_acp_runtime_exact("buzz-agent")
.expect("buzz-agent must be in catalog")
}
#[test]

View File

@@ -256,3 +256,490 @@ fn reserved_key_absent_from_definition_env_falls_through() {
assert_eq!(model.value.as_deref(), Some("persona-struct-model"));
assert_eq!(model.origin, ConfigOrigin::PersonaDefault);
}
// ── Phase 1: thought_level ACP category + alias normalization + B-collapse ───
//
// Plan v3 Phase 1: Live Goose effort is identified by ACP category
// `thought_level` (not the invented `effort` category). The matched entry's
// real `config_id` is used for AcpSetConfigOption write-back. All candidates
// are normalized before comparison so aliases compare equal to canonical
// forms (none↔off, xhigh↔max). B-collapse applies after normalization.
/// Goose real ACP shape: `category="thought_level"`, `id="thinking_effort"`,
/// canonical current value `high` → effort surfaces as `AcpConfigOption` with
/// write_via `AcpSetConfigOption { config_id: "thinking_effort" }`.
/// This pins that live Goose effort is actually read (the old `effort` category
/// would miss it entirely on a real Goose session).
#[test]
fn goose_real_acp_shape_thought_level_surfaces_and_routes_write_via_thinking_effort() {
let record = test_record();
let runtime = test_runtime(); // Goose with effort_normalization
let cache = SessionConfigCache {
config_options: vec![AcpConfigOptionEntry {
config_id: "thinking_effort".to_string(),
category: Some("thought_level".to_string()),
display_name: Some("Thinking Effort".to_string()),
current_value: Some("high".to_string()),
options: vec![],
}],
available_modes: vec![],
available_models: vec![],
current_model: None,
model_overridden: false,
goose_native_config: None,
captured_at: "".to_string(),
};
let surface = with_goose_path_root(Some("/nonexistent"), || {
read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers())
});
let effort = surface
.normalized
.thinking_effort
.expect("Goose thought_level effort must surface");
assert_eq!(effort.value.as_deref(), Some("high"));
assert_eq!(effort.origin, ConfigOrigin::AcpConfigOption);
assert!(
matches!(
&effort.write_via,
ConfigWriteMechanism::AcpSetConfigOption { config_id }
if config_id == "thinking_effort"
),
"write_via must be AcpSetConfigOption with config_id=\"thinking_effort\", got {:?}",
effort.write_via
);
}
/// ★ Both ACP categories present → `thought_level` wins, `effort` is ignored.
/// Prevents hardcoding or first-match bugs that would pick the wrong entry.
#[test]
fn thought_level_category_wins_over_effort_category_when_both_present() {
let record = test_record();
let runtime = test_runtime(); // Goose
let cache = SessionConfigCache {
config_options: vec![
// Legacy category comes first in the vec — must not win.
AcpConfigOptionEntry {
config_id: "effort".to_string(),
category: Some("effort".to_string()),
display_name: Some("Effort (legacy)".to_string()),
current_value: Some("low".to_string()),
options: vec![],
},
AcpConfigOptionEntry {
config_id: "thinking_effort".to_string(),
category: Some("thought_level".to_string()),
display_name: Some("Thinking Effort".to_string()),
current_value: Some("high".to_string()),
options: vec![],
},
],
available_modes: vec![],
available_models: vec![],
current_model: None,
model_overridden: false,
goose_native_config: None,
captured_at: "".to_string(),
};
let surface = with_goose_path_root(Some("/nonexistent"), || {
read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers())
});
let effort = surface
.normalized
.thinking_effort
.expect("effort must surface from thought_level category");
assert_eq!(
effort.value.as_deref(),
Some("high"),
"thought_level value must win"
);
assert!(
matches!(
&effort.write_via,
ConfigWriteMechanism::AcpSetConfigOption { config_id }
if config_id == "thinking_effort"
),
"write_via must use thought_level entry's config_id"
);
}
/// B-collapse alias case — `none` and `off` are the same Goose effort after
/// normalization. ACP emits canonical `off`; record env has legacy `none`.
/// After normalization both equal `off` → B-collapse applies and the panel
/// shows the true baseline origin (BuzzExplicit), not AcpConfigOption.
/// write_via must still use AcpSetConfigOption (live session) even when
/// display origin falls through.
#[test]
fn b_collapse_none_and_off_are_equal_after_normalization() {
let mut record = test_record();
// Record env carries the alias `none` (legacy write).
record
.env_vars
.insert("GOOSE_THINKING_EFFORT".to_string(), "none".to_string());
let runtime = test_runtime(); // Goose with effort_normalization
// Live ACP emits canonical `off`.
let cache = SessionConfigCache {
config_options: vec![AcpConfigOptionEntry {
config_id: "thinking_effort".to_string(),
category: Some("thought_level".to_string()),
display_name: Some("Thinking Effort".to_string()),
current_value: Some("off".to_string()),
options: vec![],
}],
available_modes: vec![],
available_models: vec![],
current_model: None,
model_overridden: false,
goose_native_config: None,
captured_at: "".to_string(),
};
let surface = with_goose_path_root(Some("/nonexistent"), || {
read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers())
});
let effort = surface
.normalized
.thinking_effort
.expect("effort must surface");
// Normalized `none` == canonical `off` — record env wins (above ACP in tier order).
// Record env is above ACP, so it wins regardless of B-collapse — BuzzExplicit.
assert_eq!(
effort.value.as_deref(),
Some("off"),
"alias normalized to canonical"
);
assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit);
}
/// B-collapse alias case — `xhigh` and `max` are the same Goose effort after
/// normalization. ACP emits canonical `max`; global env has alias `xhigh`.
/// After normalization both equal `max` → B-collapse: ACP falls through to
/// the non-ACP resolution, showing GlobalDefault as origin.
/// write_via stays AcpSetConfigOption (live session has an effort option).
#[test]
fn b_collapse_xhigh_and_max_are_equal_after_normalization() {
let record = test_record();
let runtime = test_runtime(); // Goose with effort_normalization
// Global env has alias `xhigh`.
let tiers = global_env_tiers("GOOSE_THINKING_EFFORT", "xhigh");
// Live ACP emits canonical `max`.
let cache = SessionConfigCache {
config_options: vec![AcpConfigOptionEntry {
config_id: "thinking_effort".to_string(),
category: Some("thought_level".to_string()),
display_name: Some("Thinking Effort".to_string()),
current_value: Some("max".to_string()),
options: vec![],
}],
available_modes: vec![],
available_models: vec![],
current_model: None,
model_overridden: false,
goose_native_config: None,
captured_at: "".to_string(),
};
let surface = with_goose_path_root(Some("/nonexistent"), || {
read_config_surface(&record, Some(runtime), Some(&cache), &tiers)
});
let effort = surface
.normalized
.thinking_effort
.expect("effort must surface");
// B-collapse: ACP `max` == normalized `xhigh` (`max`) → fall through to non-ACP.
// Non-ACP resolution: global env `xhigh` normalizes to `max`, origin GlobalDefault.
assert_eq!(
effort.value.as_deref(),
Some("max"),
"alias normalized to canonical"
);
assert_eq!(
effort.origin,
ConfigOrigin::GlobalDefault,
"B-collapse: equal-value ACP falls through to true baseline origin"
);
// write_via stays ACP-backed even when display provenance falls through.
assert!(
matches!(
&effort.write_via,
ConfigWriteMechanism::AcpSetConfigOption { config_id }
if config_id == "thinking_effort"
),
"equal-value collapse must retain ACP write_via with thinking_effort id"
);
}
/// Alias normalization in record env: `GOOSE_THINKING_EFFORT=none` is
/// normalized to canonical `off` before being surfaced. The panel sees
/// the canonical form, never the raw alias.
#[test]
fn goose_record_env_alias_none_normalized_to_canonical_off() {
let mut record = test_record();
record
.env_vars
.insert("GOOSE_THINKING_EFFORT".to_string(), "none".to_string());
let runtime = test_runtime();
let surface = with_goose_path_root(Some("/nonexistent"), || {
read_config_surface(&record, Some(runtime), None, &no_tiers())
});
let effort = surface
.normalized
.thinking_effort
.expect("effort must surface");
assert_eq!(
effort.value.as_deref(),
Some("off"),
"none must normalize to off"
);
assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit);
}
/// Legacy fallback: `category="effort"` is still honoured when no `thought_level`
/// entry exists (e.g. older adapters or test fixtures that predate the real category).
#[test]
fn legacy_effort_category_fallback_used_when_no_thought_level_present() {
let record = test_record();
let runtime = test_runtime(); // Goose
let cache = SessionConfigCache {
config_options: vec![AcpConfigOptionEntry {
config_id: "effort_legacy".to_string(),
category: Some("effort".to_string()),
display_name: Some("Effort".to_string()),
current_value: Some("medium".to_string()),
options: vec![],
}],
available_modes: vec![],
available_models: vec![],
current_model: None,
model_overridden: false,
goose_native_config: None,
captured_at: "".to_string(),
};
let surface = with_goose_path_root(Some("/nonexistent"), || {
read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers())
});
let effort = surface
.normalized
.thinking_effort
.expect("legacy effort category must surface as fallback");
assert_eq!(effort.value.as_deref(), Some("medium"));
assert_eq!(effort.origin, ConfigOrigin::AcpConfigOption);
// write_via uses the actual config_id from the matched entry.
assert!(
matches!(
&effort.write_via,
ConfigWriteMechanism::AcpSetConfigOption { config_id }
if config_id == "effort_legacy"
),
"fallback must retain entry's own config_id, not a hardcoded value"
);
}
// ── ACP effort normalization tests ────────────────────────────────────────────
//
// Plan v3 Delta 1: every candidate — native, legacy, ACP, file — normalized
// before validity, precedence, override tracking, and B equality.
// ACP effort must be canonicalized through `effort_norm` before comparison;
// aliases (`none`→`off`, `xhigh`→`max`, case-fold) must collapse; invalid
// values (e.g. `minimal`) must be treated as absent so lower tiers win.
/// ACP `HIGH` (wrong case) against global `high` — must normalize to `high` and B-collapse.
#[test]
fn acp_effort_case_alias_collapses_against_global_canonical() {
let record = test_record();
let runtime = test_runtime(); // Goose with effort_normalization
let cache = SessionConfigCache {
config_options: vec![AcpConfigOptionEntry {
config_id: "thinking_effort".to_string(),
category: Some("thought_level".to_string()),
display_name: Some("Thinking Effort".to_string()),
current_value: Some("HIGH".to_string()), // wrong case
options: vec![],
}],
available_modes: vec![],
available_models: vec![],
current_model: None,
model_overridden: false,
goose_native_config: None,
captured_at: "".to_string(),
};
// Global env has canonical `high`.
let mut tiers = no_tiers();
tiers
.global_env
.insert("GOOSE_THINKING_EFFORT".to_string(), "high".to_string());
let surface = with_goose_path_root(Some("/nonexistent"), || {
read_config_surface(&record, Some(runtime), Some(&cache), &tiers)
});
let effort = surface
.normalized
.thinking_effort
.expect("effort must surface");
// ACP `HIGH` normalizes to `high` == global `high` → B-collapse: falls through to
// non-ACP resolution, origin is GlobalDefault (not AcpConfigOption).
assert_eq!(
effort.value.as_deref(),
Some("high"),
"case-folded ACP value must collapse to canonical"
);
assert_eq!(
effort.origin,
ConfigOrigin::GlobalDefault,
"B-collapse must report GlobalDefault, not AcpConfigOption"
);
}
/// ACP `none` against global `off` — alias-equal after normalization, must B-collapse.
#[test]
fn acp_effort_alias_none_collapses_against_global_off() {
let record = test_record();
let runtime = test_runtime();
let cache = SessionConfigCache {
config_options: vec![AcpConfigOptionEntry {
config_id: "thinking_effort".to_string(),
category: Some("thought_level".to_string()),
display_name: Some("Thinking Effort".to_string()),
current_value: Some("none".to_string()), // alias for `off`
options: vec![],
}],
available_modes: vec![],
available_models: vec![],
current_model: None,
model_overridden: false,
goose_native_config: None,
captured_at: "".to_string(),
};
let mut tiers = no_tiers();
tiers
.global_env
.insert("GOOSE_THINKING_EFFORT".to_string(), "off".to_string());
let surface = with_goose_path_root(Some("/nonexistent"), || {
read_config_surface(&record, Some(runtime), Some(&cache), &tiers)
});
let effort = surface
.normalized
.thinking_effort
.expect("effort must surface");
assert_eq!(
effort.value.as_deref(),
Some("off"),
"ACP `none` must normalize to `off` and B-collapse with global `off`"
);
assert_eq!(effort.origin, ConfigOrigin::GlobalDefault);
}
/// ACP `xhigh` against persona `max` — alias-equal after normalization, must B-collapse.
#[test]
fn acp_effort_alias_xhigh_collapses_against_persona_max() {
let record = test_record();
let runtime = test_runtime();
let cache = SessionConfigCache {
config_options: vec![AcpConfigOptionEntry {
config_id: "thinking_effort".to_string(),
category: Some("thought_level".to_string()),
display_name: Some("Thinking Effort".to_string()),
current_value: Some("xhigh".to_string()), // alias for `max`
options: vec![],
}],
available_modes: vec![],
available_models: vec![],
current_model: None,
model_overridden: false,
goose_native_config: None,
captured_at: "".to_string(),
};
let mut tiers = no_tiers();
tiers
.persona_env
.insert("GOOSE_THINKING_EFFORT".to_string(), "max".to_string());
let surface = with_goose_path_root(Some("/nonexistent"), || {
read_config_surface(&record, Some(runtime), Some(&cache), &tiers)
});
let effort = surface
.normalized
.thinking_effort
.expect("effort must surface");
assert_eq!(
effort.value.as_deref(),
Some("max"),
"ACP `xhigh` must normalize to `max` and B-collapse with persona `max`"
);
assert_eq!(effort.origin, ConfigOrigin::PersonaDefault);
}
/// ACP value invalid for Goose (`minimal`) — must be skipped; lower valid candidate wins.
#[test]
fn acp_effort_invalid_for_runtime_skipped_lower_tier_wins() {
let record = test_record();
let runtime = test_runtime();
let cache = SessionConfigCache {
config_options: vec![AcpConfigOptionEntry {
config_id: "thinking_effort".to_string(),
category: Some("thought_level".to_string()),
display_name: Some("Thinking Effort".to_string()),
current_value: Some("minimal".to_string()), // Goose does not accept "minimal"
options: vec![],
}],
available_modes: vec![],
available_models: vec![],
current_model: None,
model_overridden: false,
goose_native_config: None,
captured_at: "".to_string(),
};
// Global env has a valid Goose effort — should win because ACP `minimal` is skipped.
let mut tiers = no_tiers();
tiers
.global_env
.insert("GOOSE_THINKING_EFFORT".to_string(), "medium".to_string());
let surface = with_goose_path_root(Some("/nonexistent"), || {
read_config_surface(&record, Some(runtime), Some(&cache), &tiers)
});
let effort = surface
.normalized
.thinking_effort
.expect("effort must surface from global tier");
assert_eq!(
effort.value.as_deref(),
Some("medium"),
"invalid ACP `minimal` must be skipped; global `medium` must win"
);
assert_eq!(
effort.origin,
ConfigOrigin::GlobalDefault,
"origin must be GlobalDefault after ACP skip"
);
// write_via stays AcpSetConfigOption — the route targets the live option,
// not its current value; an invalid live value changes display resolution
// but not writability (plan v3 Phase 1 ruling).
assert!(
matches!(
&effort.write_via,
ConfigWriteMechanism::AcpSetConfigOption { config_id }
if config_id == "thinking_effort"
),
"write_via must remain AcpSetConfigOption{{thinking_effort}} even when ACP value is invalid; got {:?}",
effort.write_via
);
}

View File

@@ -18,7 +18,9 @@ pub(crate) use presets::{
preset_harness_ids,
};
use presets::{preset_catalog_entry, PRESET_HARNESSES};
pub(crate) use runtime_metadata::KnownAcpRuntime;
pub(crate) use runtime_metadata::{
EffortNormalization, KnownAcpRuntime, GOOSE_EFFORT_NORMALIZATION,
};
const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png";
const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default";
@@ -104,6 +106,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
config_file_format: Some("yaml"),
supports_acp_native_config: true,
thinking_env_var: Some("GOOSE_THINKING_EFFORT"),
effort_normalization: Some(&GOOSE_EFFORT_NORMALIZATION),
max_tokens_env_var: Some("GOOSE_MAX_TOKENS"),
context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"),
max_rounds_env_var: None,
@@ -137,6 +140,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
config_file_format: Some("json"),
supports_acp_native_config: false,
thinking_env_var: None,
effort_normalization: None,
max_tokens_env_var: None,
context_limit_env_var: None,
max_rounds_env_var: None,
@@ -170,6 +174,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
config_file_format: Some("toml"),
supports_acp_native_config: false,
thinking_env_var: None,
effort_normalization: None,
max_tokens_env_var: None,
context_limit_env_var: None,
max_rounds_env_var: None,
@@ -204,6 +209,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
config_file_format: None,
supports_acp_native_config: false,
thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"),
effort_normalization: None, // buzz-agent: per-model catalog; see getProviderEffortConfig() in TS
max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"),
context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"),
max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"),
@@ -581,13 +587,9 @@ pub fn clear_resolve_cache() {
// ── Adapter availability cache (Phase-2 badge fallback) ─────────────────────
//
// `build_managed_agent_summary` needs to compare the spawn-time adapter
// availability against the *current* availability without triggering a live
// `probe_codex_acp_version` subprocess on every poll cycle. This cache
// stores the last availability status of the codex-acp binary at its resolved
// path. It is warmed by `discover_acp_runtimes` (which already probes), so
// the badge path reads warm data, and is invalidated by `clear_resolve_cache`
// (called on every Doctor install and every `discover_acp_providers` call).
// Warmed by `discover_acp_runtimes`; invalidated by `clear_resolve_cache`.
// Allows `build_managed_agent_summary` to compare adapter availability without
// spawning a probe subprocess on every poll cycle.
fn adapter_availability_cache() -> &'static std::sync::Mutex<Option<AcpAvailabilityStatus>> {
use std::sync::{Mutex, OnceLock};
@@ -716,10 +718,7 @@ fn resolve_command_uncached(command: &str) -> Option<PathBuf> {
}
}
// Check nvm's default Node.js bin directory — nvm initializes via
// ~/.zshrc (interactive) which is not loaded by a login shell, so
// `node`, `npm`, and npm-global shims installed there are otherwise
// invisible.
// Check nvm's default Node.js bin directory (not on PATH in login shells).
if let Some(home) = dirs::home_dir() {
if let Some(nvm_bin) = find_nvm_default_bin(&home) {
for basename in &basenames {
@@ -1390,6 +1389,9 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr
model_env_var: runtime.model_env_var.map(str::to_string),
provider_env_var: runtime.provider_env_var.map(str::to_string),
thinking_env_var: runtime.thinking_env_var.map(str::to_string),
accepted_effort_values: runtime
.effort_normalization
.map(|n| n.canonical_values().iter().map(|s| s.to_string()).collect()),
max_tokens_env_var: runtime.max_tokens_env_var.map(str::to_string),
context_limit_env_var: runtime.context_limit_env_var.map(str::to_string),
max_rounds_env_var: runtime.max_rounds_env_var.map(str::to_string),
@@ -1551,6 +1553,7 @@ pub fn discover_acp_runtimes_from(
model_env_var: None,
provider_env_var: None,
thinking_env_var: None,
accepted_effort_values: None,
max_tokens_env_var: None,
context_limit_env_var: None,
max_rounds_env_var: None,

View File

@@ -67,6 +67,7 @@ pub(super) fn preset_catalog_entry(
model_env_var: None,
provider_env_var: None,
thinking_env_var: None,
accepted_effort_values: None,
max_tokens_env_var: None,
context_limit_env_var: None,
max_rounds_env_var: None,

View File

@@ -1,3 +1,62 @@
/// Canonicalization contract for a harness's thinking-effort env var.
///
/// The authority for UI choices, spawn bridge, and reader. All candidates
/// are normalized through `normalize()` before any validity, precedence,
/// override, or B-equality check.
///
/// Source for Goose: `crates/goose-provider-types/src/thinking.rs`
/// • `FromStr` (aliases, case-insensitive): `off|disabled|none`, `low`,
/// `medium|med`, `high`, `max|xhigh`
/// • `Display` (canonical): `off`, `low`, `medium`, `high`, `max`
/// • Live ACP emits Display values via `response_builder.rs:326-337`.
pub(crate) struct EffortNormalization {
/// Canonical values in UI display order (drive choices, persistence, ACP comparison).
pub canonical: &'static [&'static str],
/// `(alias, canonical)` pairs, case-insensitive. Only aliases that differ
/// from their canonical form are listed.
pub aliases: &'static [(&'static str, &'static str)],
}
/// Goose thinking-effort canonicalization contract.
///
/// Source: `crates/goose-provider-types/src/thinking.rs` at Goose `2db0e31fe`.
/// Canonical Display values: `off`, `low`, `medium`, `high`, `max`.
/// Aliases (case-insensitive): `none|disabled→off`, `med→medium`, `xhigh→max`.
/// `minimal` (Buzz-only) is invalid — skipped as absent at every tier.
pub(crate) static GOOSE_EFFORT_NORMALIZATION: EffortNormalization = EffortNormalization {
canonical: &["off", "low", "medium", "high", "max"],
aliases: &[
("none", "off"),
("disabled", "off"),
("med", "medium"),
("xhigh", "max"),
],
};
impl EffortNormalization {
/// Normalize `raw` to a `String` canonical form.
/// `None` → invalid for this harness; caller must treat as absent (skip-as-absent policy).
pub fn normalize_str(&self, raw: &str) -> Option<String> {
let lower = raw.to_lowercase();
// Check direct canonical match first.
if self.canonical.contains(&lower.as_str()) {
return Some(lower);
}
// Check aliases.
for &(alias, canon) in self.aliases {
if lower == alias {
return Some(canon.to_string());
}
}
None
}
/// Return the canonical values slice (for UI choices).
pub fn canonical_values(&self) -> &'static [&'static str] {
self.canonical
}
}
/// Static capabilities and installation metadata for a known ACP runtime.
pub(crate) struct KnownAcpRuntime {
pub id: &'static str,
@@ -47,6 +106,20 @@ pub(crate) struct KnownAcpRuntime {
pub config_file_format: Option<&'static str>,
pub supports_acp_native_config: bool, // tier 1a: config/read+write
pub thinking_env_var: Option<&'static str>,
/// Canonicalization contract for `thinking_env_var` on this harness.
///
/// `Some(contract)` — harness uses a finite, static effort vocabulary.
/// All candidates (native env, legacy env, ACP tier, file tier) are
/// normalized through this contract before validity checks, precedence
/// resolution, override tracking, and B-equality comparison.
///
/// `None` — harness accepts any provider/model-specific value via its own
/// catalog (buzz-agent); see `getProviderEffortConfig()` in TS for that path.
///
/// The single canonical authority shared by UI choices, spawn bridge, and
/// reader. No value-authority logic may live outside this struct for
/// harnesses that declare one.
pub effort_normalization: Option<&'static EffortNormalization>,
/// Env var for normalizing `max_output_tokens`. `None` when the harness
/// does not have a first-class env var for this field (config-file only).
pub max_tokens_env_var: Option<&'static str>,

View File

@@ -70,8 +70,6 @@ pub(crate) struct EffectiveAgentEnv {
/// The process-env map the spawned harness would receive.
pub env: BTreeMap<String, String>,
/// Harness config file path, if any (e.g. `~/.config/goose/config.yaml`).
// Not read yet; kept for the unified-agent-record rewrite (chunk A) which
// replaces this resolution path wholesale.
#[allow(dead_code)]
pub config_file_path: Option<&'static str>,
/// The resolved harness binary name (e.g. `"buzz-agent"`, `"goose"`).
@@ -1049,6 +1047,7 @@ mod tests {
default_env: &[],
supports_acp_native_config: false,
thinking_env_var: None,
effort_normalization: None,
max_tokens_env_var: None,
context_limit_env_var: None,
max_rounds_env_var: None,
@@ -1241,6 +1240,7 @@ mod tests {
default_env: &[],
supports_acp_native_config: false,
thinking_env_var: None,
effort_normalization: None,
max_tokens_env_var: None,
context_limit_env_var: None,
max_rounds_env_var: None,

View File

@@ -642,6 +642,8 @@ pub struct AcpRuntimeCatalogEntry {
pub provider_env_var: Option<String>,
/// Environment variable used to apply thinking effort, when supported.
pub thinking_env_var: Option<String>,
/// Canonical effort values (`off|low|medium|high|max` for Goose; `None` for per-model harnesses).
pub accepted_effort_values: Option<Vec<String>>,
pub max_tokens_env_var: Option<String>,
pub context_limit_env_var: Option<String>,
pub max_rounds_env_var: Option<String>,
@@ -664,12 +666,7 @@ pub struct AcpRuntimeCatalogEntry {
/// JSON file in `custom_harnesses/`. The UI uses this to decide editability.
pub source: HarnessSource,
/// Definition-level environment variables for `source: custom` entries.
///
/// Populated from `HarnessDefinition.env` so the edit form can read them
/// back and the user doesn't silently lose env vars when saving. Always
/// empty for `builtin` and `preset` entries (those env values come from the
/// runtime metadata path, not user-editable JSON).
///
/// Populated from `HarnessDefinition.env`; always empty for builtin/preset entries.
/// Skipped in serialization when empty to keep the catalog payload compact.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub definition_env: BTreeMap<String, String>,