mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
feat(agents): canonical effort contract + native-key persistence + mutation contract (Phase 2)
- acceptedEffortValues on AcpRuntimeCatalogEntry: Goose carries canonical off|low|medium|high|max (source: goose thinking.rs:277-308); null for buzz-agent (per-model catalog). Single authority for UI, spawn bridge, and reader. - normalizeEffortValue() in buzzAgentConfig.ts: aliases none|disabled→off, med→medium, xhigh→max (case-insensitive); null acceptedValues = pass-through (buzz-agent path unchanged). - ALL_KNOWN_EFFORT_KEYS in buzzAgentConfig.ts mirrors Rust constant; used for record/persona runtime-transition cleanup. - agentConfigCore.ts: native key wins; legacy-only pre-migration save shown via effortValue fallback; legacyConsumedKey suppresses duplicate advanced row. optionSource harnessNative when acceptedEffortValues present. - AgentConfigFields.tsx: descriptor-driven global/onboarding path for harnessNative runtimes (isHarnessNativeEffort): canonical options from acceptedEffortValues, currentEffort from normalised descriptor value, effortValidForAutoClear from acceptedEffortValues. Unconditional BUZZ_AGENT_THINKING_EFFORT deletes guarded by !isHarnessNativeEffort (Delta 5: global-scope coexistence). allStructuredKeys includes legacyConsumedKey; effort clear deletes native + legacy; bakedStructuredKeys() replaces static set. - buzzAgentModelTuningFields.tsx: applyHarnessNativeEffortChange() exported production helper — single spec for the mutation contract. legacyKey=null at global/onboarding scope (Delta 5), legacyKey=BUZZ_AGENT_THINKING_EFFORT at record/persona scope. EffortSelectField gains canonicalValues prop to bypass Buzz 7-value catalog. HarnessNativeEffortFields calls production helper. - personaRuntimeModel.ts: resolveInheritedRuntimeSubmission gains excludePersonaEnvKeys param. Strips harness-native effort keys from persona layer on inherit-transition so the record never acquires a frozen copy of persona effort (effort inherits at spawn; it is not a credential). - AgentInstanceEditDialog.tsx: passes excludePersonaEnvKeys with runtime's thinkingEnvVar + ALL_KNOWN_EFFORT_KEYS when runtime has acceptedEffortValues. - bakedEnvHelpers.ts: getInheritedAgentDefaults accepts nativeEffortKey option; baked lookup is native-only for non-buzz-agent runtimes (Delta-4 boundary). bakedStructuredKeys() exported for AgentConfigFields. - useAgentDialogDefaults.ts: accepts nativeEffortKey; seeds inherited env under native key. - AgentDefinitionDialog + AgentInstanceEditDialog: pass prev/next native effort keys to selectionOnRuntimeChange; handleInheritHarnessChange clears all known effort keys on pin↔inherit toggle. - runtimeModelProviderSelection.ts: selectionOnRuntimeChange clears prev native + next native + legacy at record/persona scope; skip when native === legacy (buzz-agent same-key invariant). - agentConfigOptions.tsx: resetConfigForHarnessChange preserves all effort keys (global-scope Delta-5 rule; each runtime's descriptor strips foreign keys). - agent_config.rs: is_safe_to_reveal derives allowed effort keys from ALL_KNOWN_EFFORT_KEYS so GOOSE_THINKING_EFFORT is unmasked at baked tier. - AGENTS.md rule-2 'until PR 2.7' clause removed: native-key persistence lands in this commit. - Test coverage: applyHarnessNativeEffortChange record/persona + global scope pins; resolveInheritedRuntimeSubmission inherit-no-materialize (native + legacy), concrete-override-wins, non-effort-merged, full-submit-payload-clean; global Goose descriptor optionSource/value/invalid/no-legacy pins; runtimeModelProviderSelection useLiteralKeys cleanup. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
parent
66d77dc535
commit
25dd24a11d
@@ -209,6 +209,32 @@ pub struct BakedEnvEntry {
|
||||
pub masked: bool,
|
||||
}
|
||||
|
||||
/// Returns `true` when a baked-env key is safe to display unmasked in the UI.
|
||||
///
|
||||
/// This uses an explicit allowlist of keys that are known safe (non-secret).
|
||||
/// Any key NOT in this set is masked — default-deny for a security surface.
|
||||
///
|
||||
/// Allowlist (case-insensitive):
|
||||
/// - `BUZZ_AGENT_PROVIDER`, `BUZZ_AGENT_MODEL` — agent runtime selection
|
||||
/// - All known native thinking-effort keys (non-secret enum values) — derived
|
||||
/// from `ALL_KNOWN_EFFORT_KEYS` so this list stays in sync with runtime
|
||||
/// metadata declarations (`BUZZ_AGENT_THINKING_EFFORT`, `GOOSE_THINKING_EFFORT`).
|
||||
/// - `DATABRICKS_HOST`, `DATABRICKS_MODEL` — Block non-secret defaults
|
||||
fn is_safe_to_reveal(key: &str) -> bool {
|
||||
use crate::managed_agents::config_bridge::ALL_KNOWN_EFFORT_KEYS;
|
||||
const SAFE_KEYS: &[&str] = &[
|
||||
"BUZZ_AGENT_PROVIDER",
|
||||
"BUZZ_AGENT_MODEL",
|
||||
"DATABRICKS_HOST",
|
||||
"DATABRICKS_MODEL",
|
||||
];
|
||||
let upper = key.to_ascii_uppercase();
|
||||
SAFE_KEYS.iter().any(|safe| upper == *safe)
|
||||
|| ALL_KNOWN_EFFORT_KEYS
|
||||
.iter()
|
||||
.any(|effort| upper == effort.to_ascii_uppercase())
|
||||
}
|
||||
|
||||
/// Expose the baked build env to the frontend with values shown, but any
|
||||
/// key not in the safe-to-reveal allowlist has its value replaced by `••••••`.
|
||||
///
|
||||
|
||||
@@ -605,6 +605,21 @@ fn baked_env_thinking_effort_is_unmasked() {
|
||||
assert!(!effort.masked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baked_env_goose_thinking_effort_is_unmasked() {
|
||||
// GOOSE_THINKING_EFFORT is a non-secret canonical enum (off/low/medium/high/max) —
|
||||
// must be revealed so the native baked lookup in `bakedEnvHelpers.ts` can read
|
||||
// it as inherited effort for Goose agents (plan v3 pass-3 ★ pin).
|
||||
let entries = baked_env_from_map(&[("GOOSE_THINKING_EFFORT", "high")]);
|
||||
assert_eq!(entries.len(), 1);
|
||||
let effort = entries
|
||||
.iter()
|
||||
.find(|e| e.key == "GOOSE_THINKING_EFFORT")
|
||||
.unwrap();
|
||||
assert_eq!(effort.value, "high");
|
||||
assert!(!effort.masked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baked_env_allowlist_is_case_insensitive() {
|
||||
// Known-safe keys — case-insensitive match must allow them.
|
||||
@@ -614,6 +629,9 @@ fn baked_env_allowlist_is_case_insensitive() {
|
||||
assert!(super::is_safe_to_reveal("BUZZ_AGENT_MODEL"));
|
||||
assert!(super::is_safe_to_reveal("buzz_agent_thinking_effort"));
|
||||
assert!(super::is_safe_to_reveal("BUZZ_AGENT_THINKING_EFFORT"));
|
||||
// Goose native effort key — derived from ALL_KNOWN_EFFORT_KEYS.
|
||||
assert!(super::is_safe_to_reveal("goose_thinking_effort"));
|
||||
assert!(super::is_safe_to_reveal("GOOSE_THINKING_EFFORT"));
|
||||
assert!(super::is_safe_to_reveal("databricks_host"));
|
||||
assert!(super::is_safe_to_reveal("DATABRICKS_HOST"));
|
||||
assert!(super::is_safe_to_reveal("databricks_model"));
|
||||
|
||||
@@ -586,10 +586,8 @@ pub fn clear_resolve_cache() {
|
||||
}
|
||||
|
||||
// ── Adapter availability cache (Phase-2 badge fallback) ─────────────────────
|
||||
//
|
||||
// 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.
|
||||
// Allows `build_managed_agent_summary` to check adapter availability without re-probing.
|
||||
|
||||
fn adapter_availability_cache() -> &'static std::sync::Mutex<Option<AcpAvailabilityStatus>> {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
@@ -1547,8 +1545,7 @@ pub fn discover_acp_runtimes_from(
|
||||
command,
|
||||
binary_path,
|
||||
default_args,
|
||||
// Custom harnesses are plain ACP — no MCP sidecar, no env-var
|
||||
// model switching, no thinking knobs.
|
||||
// Custom harnesses are plain ACP — no MCP sidecar, no env-var model switching, no thinking knobs.
|
||||
mcp_command: None,
|
||||
model_env_var: None,
|
||||
provider_env_var: None,
|
||||
|
||||
@@ -28,6 +28,7 @@ function runtime(id, metadata = {}) {
|
||||
modelEnvVar: null,
|
||||
providerEnvVar: null,
|
||||
thinkingEnvVar: null,
|
||||
acceptedEffortValues: null,
|
||||
maxTokensEnvVar: null,
|
||||
contextLimitEnvVar: null,
|
||||
maxRoundsEnvVar: null,
|
||||
@@ -75,6 +76,34 @@ test("Goose exposes provider, model, and its real effort application key", () =>
|
||||
modelEnvVar: "GOOSE_MODEL",
|
||||
providerEnvVar: "GOOSE_PROVIDER",
|
||||
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
|
||||
acceptedEffortValues: ["off", "low", "medium", "high", "max"],
|
||||
}),
|
||||
scope: "global",
|
||||
});
|
||||
|
||||
// With acceptedEffortValues, optionSource is "harnessNative" (single metadata authority).
|
||||
assert.equal(field(model, "effort").optionSource, "harnessNative");
|
||||
// Persistence key is the native key, not the legacy key.
|
||||
assert.deepEqual(field(model, "effort").currentPersistence, {
|
||||
kind: "envVar",
|
||||
key: "GOOSE_THINKING_EFFORT",
|
||||
});
|
||||
assert.deepEqual(field(model, "effort").targetApplication, {
|
||||
kind: "envVar",
|
||||
key: "GOOSE_THINKING_EFFORT",
|
||||
});
|
||||
});
|
||||
|
||||
test("Goose without acceptedEffortValues falls back to legacyProviderModelCatalog", () => {
|
||||
// When a Goose catalog entry predates acceptedEffortValues (null), the field
|
||||
// falls back to legacyProviderModelCatalog — existing behavior preserved.
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config,
|
||||
runtime: runtime("goose", {
|
||||
modelEnvVar: "GOOSE_MODEL",
|
||||
providerEnvVar: "GOOSE_PROVIDER",
|
||||
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
|
||||
acceptedEffortValues: null, // explicit null → legacy path
|
||||
}),
|
||||
scope: "global",
|
||||
});
|
||||
@@ -83,16 +112,47 @@ test("Goose exposes provider, model, and its real effort application key", () =>
|
||||
field(model, "effort").optionSource,
|
||||
"legacyProviderModelCatalog",
|
||||
);
|
||||
// Native key is still used as persistence key (v3 Phase 2 always uses native key).
|
||||
assert.deepEqual(field(model, "effort").currentPersistence, {
|
||||
kind: "envVar",
|
||||
key: "BUZZ_AGENT_THINKING_EFFORT",
|
||||
});
|
||||
assert.deepEqual(field(model, "effort").targetApplication, {
|
||||
kind: "envVar",
|
||||
key: "GOOSE_THINKING_EFFORT",
|
||||
});
|
||||
});
|
||||
|
||||
test("Goose reads value from native key, falls back to legacy pre-migration save", () => {
|
||||
// With both keys present, native wins (global scope — native-wins applies at all tiers).
|
||||
const modelBothKeys = deriveAgentConfigFieldModel({
|
||||
config: {
|
||||
...config,
|
||||
env_vars: {
|
||||
GOOSE_THINKING_EFFORT: "medium",
|
||||
BUZZ_AGENT_THINKING_EFFORT: "high",
|
||||
},
|
||||
},
|
||||
runtime: runtime("goose", {
|
||||
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
|
||||
acceptedEffortValues: ["off", "low", "medium", "high", "max"],
|
||||
}),
|
||||
scope: "global",
|
||||
});
|
||||
assert.equal(field(modelBothKeys, "effort").value, "medium");
|
||||
|
||||
// With only legacy key, read-old fallback kicks in at record/persona tier.
|
||||
// At global scope, legacy is excluded (Delta 4 / effort_tier_alias(global_tier=true)).
|
||||
const modelLegacyOnly = deriveAgentConfigFieldModel({
|
||||
config: {
|
||||
...config,
|
||||
env_vars: { BUZZ_AGENT_THINKING_EFFORT: "high" },
|
||||
},
|
||||
runtime: runtime("goose", {
|
||||
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
|
||||
acceptedEffortValues: ["off", "low", "medium", "high", "max"],
|
||||
}),
|
||||
scope: "instance",
|
||||
});
|
||||
assert.equal(field(modelLegacyOnly, "effort").value, "high");
|
||||
});
|
||||
|
||||
test("Claude models effort as a deferred native ACP option", () => {
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config,
|
||||
@@ -405,34 +465,35 @@ test("structuredEnvKeys_per_agent_buzz_agent_includes_effort_and_numeric_keys",
|
||||
assert.ok(keys.includes("BUZZ_AGENT_MAX_ROUNDS"), "maxRounds present");
|
||||
});
|
||||
|
||||
test("structuredEnvKeys_per_agent_goose_excludes_effort_key_discriminating_invariant", () => {
|
||||
// Per-agent Goose: effort migration is out of scope, so no effort control
|
||||
// renders on the per-agent surface for Goose. Only the 2 numeric descriptors
|
||||
// are passed as the rendered set. The effort persistence key
|
||||
// (BUZZ_AGENT_THINKING_EFFORT) must NOT appear in the output — any saved
|
||||
// value must remain visible and editable as a generic env row.
|
||||
test("structuredEnvKeys_per_agent_goose_includes_effort_and_numeric_keys", () => {
|
||||
// Per-agent Goose with acceptedEffortValues now renders an effort control —
|
||||
// the native key (GOOSE_THINKING_EFFORT) must appear in the structured set.
|
||||
// The legacy key (BUZZ_AGENT_THINKING_EFFORT) must NOT appear — it has no
|
||||
// editor on this surface and must stay visible as a generic env row.
|
||||
const gooseModel = deriveAgentConfigFieldModel({
|
||||
config,
|
||||
runtime: runtime("goose", {
|
||||
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
|
||||
acceptedEffortValues: ["off", "low", "medium", "high", "max"],
|
||||
maxTokensEnvVar: "GOOSE_MAX_TOKENS",
|
||||
contextLimitEnvVar: "GOOSE_CONTEXT_LIMIT",
|
||||
}),
|
||||
scope: "definition",
|
||||
});
|
||||
|
||||
// Simulate per-agent surface: only the numeric descriptors render (no effort
|
||||
// control for Goose per-agent — effort migration is out of scope).
|
||||
const numericDescriptorsOnly = gooseModel.fields.filter((f) =>
|
||||
["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind),
|
||||
const renderedDescriptors = gooseModel.fields.filter(
|
||||
(f) => f.render === "control",
|
||||
);
|
||||
const keys = structuredEnvKeys(renderedDescriptors);
|
||||
|
||||
const keys = structuredEnvKeys(numericDescriptorsOnly);
|
||||
|
||||
assert.ok(
|
||||
keys.includes("GOOSE_THINKING_EFFORT"),
|
||||
"Goose native effort key must be hidden (control renders)",
|
||||
);
|
||||
assert.equal(
|
||||
keys.includes("BUZZ_AGENT_THINKING_EFFORT"),
|
||||
false,
|
||||
"effort persistence key must NOT be hidden for Goose per-agent — no editor would replace it",
|
||||
"legacy key must NOT be hidden — no editor owns it for Goose",
|
||||
);
|
||||
assert.ok(
|
||||
keys.includes("GOOSE_MAX_TOKENS"),
|
||||
@@ -561,3 +622,212 @@ test("NUMERIC_KIND_MIN_contextLimit_is_1", () => {
|
||||
test("NUMERIC_KIND_MIN_maxRounds_is_0", () => {
|
||||
assert.equal(NUMERIC_KIND_MIN.maxRounds, 0);
|
||||
});
|
||||
|
||||
test("unconsumed_legacy_effort_row_stays_visible_for_goose", () => {
|
||||
// An invalid-for-Goose legacy value in a Goose record (BUZZ_AGENT_THINKING_EFFORT=minimal)
|
||||
// must remain visible as a generic advanced-env row — it is not consumed as structured
|
||||
// effort and must not be hidden. Only the native key (GOOSE_THINKING_EFFORT) is in
|
||||
// structuredEnvKeys for Goose. (plan v3 pass-3 ★ pin: unconsumed legacy rows stay visible.)
|
||||
// Uses `minimal` — invalid for Goose's canonical vocabulary — to pin the normalization
|
||||
// rejection path: the value is present but normalization returns null → not consumed.
|
||||
const gooseModel = deriveAgentConfigFieldModel({
|
||||
config: {
|
||||
...config,
|
||||
env_vars: { BUZZ_AGENT_THINKING_EFFORT: "minimal" },
|
||||
},
|
||||
runtime: runtime("goose", {
|
||||
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
|
||||
acceptedEffortValues: ["off", "low", "medium", "high", "max"],
|
||||
}),
|
||||
scope: "definition",
|
||||
});
|
||||
const renderedDescriptors = gooseModel.fields.filter(
|
||||
(f) => f.render === "control",
|
||||
);
|
||||
const keys = structuredEnvKeys(renderedDescriptors);
|
||||
// Legacy key is NOT in structured keys for Goose → stays visible as advanced row.
|
||||
assert.equal(
|
||||
keys.includes("BUZZ_AGENT_THINKING_EFFORT"),
|
||||
false,
|
||||
"legacy key must not be hidden for Goose — no structured control owns it",
|
||||
);
|
||||
// Invalid legacy value is not consumed → effort control shows empty.
|
||||
assert.equal(
|
||||
field(gooseModel, "effort").value,
|
||||
null,
|
||||
"invalid legacy `minimal` must not be consumed — effort control is empty",
|
||||
);
|
||||
});
|
||||
|
||||
test("goose_legacy_only_persona_scope_renders_canonical_value", () => {
|
||||
// Legacy-only Goose persona (scope="definition") — the legacy fallback must
|
||||
// apply at this scope and render the normalized canonical value.
|
||||
// Exercises the legacy read path for the persona editor surface specifically.
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config: {
|
||||
...config,
|
||||
env_vars: { BUZZ_AGENT_THINKING_EFFORT: "high" },
|
||||
},
|
||||
runtime: runtime("goose", {
|
||||
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
|
||||
acceptedEffortValues: ["off", "low", "medium", "high", "max"],
|
||||
}),
|
||||
scope: "definition",
|
||||
});
|
||||
assert.equal(
|
||||
field(model, "effort").value,
|
||||
"high",
|
||||
"valid legacy value at persona scope must be shown as canonical effort value",
|
||||
);
|
||||
assert.equal(
|
||||
field(model, "effort").legacyConsumedKey,
|
||||
"BUZZ_AGENT_THINKING_EFFORT",
|
||||
"legacyConsumedKey set at persona scope so the duplicate env row is hidden",
|
||||
);
|
||||
});
|
||||
|
||||
test("goose_alias_none_normalizes_to_off_in_display_value", () => {
|
||||
// Pre-migration Goose record with BUZZ_AGENT_THINKING_EFFORT=none.
|
||||
// The display value must be normalized to "off" (canonical) not "none" (alias).
|
||||
// Uses scope: "instance" — legacy is consumed only at record/persona tiers.
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config: {
|
||||
...config,
|
||||
env_vars: { BUZZ_AGENT_THINKING_EFFORT: "none" },
|
||||
},
|
||||
runtime: runtime("goose", {
|
||||
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
|
||||
acceptedEffortValues: ["off", "low", "medium", "high", "max"],
|
||||
}),
|
||||
scope: "instance",
|
||||
});
|
||||
assert.equal(
|
||||
field(model, "effort").value,
|
||||
"off",
|
||||
"none (Buzz alias) must normalize to off (Goose canonical) for display",
|
||||
);
|
||||
});
|
||||
|
||||
test("goose_alias_xhigh_normalizes_to_max_in_display_value", () => {
|
||||
// Pre-migration Goose record with BUZZ_AGENT_THINKING_EFFORT=xhigh.
|
||||
// The display value must be normalized to "max" (canonical).
|
||||
// Uses scope: "instance" — legacy is consumed only at record/persona tiers.
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config: {
|
||||
...config,
|
||||
env_vars: { BUZZ_AGENT_THINKING_EFFORT: "xhigh" },
|
||||
},
|
||||
runtime: runtime("goose", {
|
||||
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
|
||||
acceptedEffortValues: ["off", "low", "medium", "high", "max"],
|
||||
}),
|
||||
scope: "instance",
|
||||
});
|
||||
assert.equal(
|
||||
field(model, "effort").value,
|
||||
"max",
|
||||
"xhigh (Buzz alias) must normalize to max (Goose canonical) for display",
|
||||
);
|
||||
});
|
||||
|
||||
test("goose_invalid_legacy_effort_shows_null_value_not_alias", () => {
|
||||
// BUZZ_AGENT_THINKING_EFFORT=minimal is invalid for Goose (no alias). Display
|
||||
// value must be null (skip-as-absent), not the raw invalid string.
|
||||
// Uses scope: "instance" — legacy is consumed only at record/persona tiers.
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config: {
|
||||
...config,
|
||||
env_vars: { BUZZ_AGENT_THINKING_EFFORT: "minimal" },
|
||||
},
|
||||
runtime: runtime("goose", {
|
||||
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
|
||||
acceptedEffortValues: ["off", "low", "medium", "high", "max"],
|
||||
}),
|
||||
scope: "instance",
|
||||
});
|
||||
assert.equal(
|
||||
field(model, "effort").value,
|
||||
null,
|
||||
"minimal (invalid for Goose) must yield null display value",
|
||||
);
|
||||
});
|
||||
|
||||
test("goose_legacy_consumed_as_effort_reports_legacyConsumedKey", () => {
|
||||
// When a legacy value is consumed as Goose effort at record/persona tier
|
||||
// (scope "instance"), the descriptor must set legacyConsumedKey so callers
|
||||
// can suppress the dup row. Legacy is NOT consumed at global scope.
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config: {
|
||||
...config,
|
||||
env_vars: { BUZZ_AGENT_THINKING_EFFORT: "high" },
|
||||
},
|
||||
runtime: runtime("goose", {
|
||||
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
|
||||
acceptedEffortValues: ["off", "low", "medium", "high", "max"],
|
||||
}),
|
||||
scope: "instance",
|
||||
});
|
||||
assert.equal(
|
||||
field(model, "effort").legacyConsumedKey,
|
||||
"BUZZ_AGENT_THINKING_EFFORT",
|
||||
"legacyConsumedKey must be set when legacy key value is shown via fallback",
|
||||
);
|
||||
});
|
||||
|
||||
test("goose_global_scope_legacy_not_consumed_effort_empty_and_row_visible", () => {
|
||||
// Plan v3 Delta 4 pin: at global scope, BUZZ_AGENT_THINKING_EFFORT is
|
||||
// buzz-agent's native key and must NEVER be consumed as Goose effort.
|
||||
// The effort control must show empty; the legacy key must NOT appear in
|
||||
// structuredEnvKeys (so it remains a visible advanced row).
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config: {
|
||||
...config,
|
||||
env_vars: { BUZZ_AGENT_THINKING_EFFORT: "high" },
|
||||
},
|
||||
runtime: runtime("goose", {
|
||||
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
|
||||
acceptedEffortValues: ["off", "low", "medium", "high", "max"],
|
||||
}),
|
||||
scope: "global",
|
||||
});
|
||||
assert.equal(
|
||||
field(model, "effort").value,
|
||||
null,
|
||||
"Goose effort control must be empty at global scope with only legacy key",
|
||||
);
|
||||
assert.equal(
|
||||
field(model, "effort").legacyConsumedKey,
|
||||
undefined,
|
||||
"legacyConsumedKey must be undefined at global scope — legacy stays a visible row",
|
||||
);
|
||||
// Legacy key must NOT be hidden (not in structuredEnvKeys).
|
||||
const renderedDescriptors = model.fields.filter(
|
||||
(f) => f.render === "control",
|
||||
);
|
||||
const keys = structuredEnvKeys(renderedDescriptors);
|
||||
assert.equal(
|
||||
keys.includes("BUZZ_AGENT_THINKING_EFFORT"),
|
||||
false,
|
||||
"legacy key must remain a visible advanced row at global scope for Goose",
|
||||
);
|
||||
});
|
||||
|
||||
test("goose_native_key_set_does_not_report_legacyConsumedKey", () => {
|
||||
// When native key is present, legacyConsumedKey must be undefined (no dup row).
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config: {
|
||||
...config,
|
||||
env_vars: { GOOSE_THINKING_EFFORT: "medium" },
|
||||
},
|
||||
runtime: runtime("goose", {
|
||||
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
|
||||
acceptedEffortValues: ["off", "low", "medium", "high", "max"],
|
||||
}),
|
||||
scope: "global",
|
||||
});
|
||||
assert.equal(
|
||||
field(model, "effort").legacyConsumedKey,
|
||||
undefined,
|
||||
"legacyConsumedKey must be undefined when native key is set",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,10 @@ import type {
|
||||
AcpRuntimeCatalogEntry,
|
||||
GlobalAgentConfig,
|
||||
} from "@/shared/api/types";
|
||||
import { BUZZ_AGENT_THINKING_EFFORT } from "../ui/buzzAgentConfig";
|
||||
import {
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
normalizeEffortValue,
|
||||
} from "../ui/buzzAgentConfig";
|
||||
|
||||
/**
|
||||
* Lifecycle status of the ACP runtime catalog query on a per-agent surface.
|
||||
@@ -81,6 +84,9 @@ export type AgentConfigFieldDescriptor =
|
||||
| { kind: "acpConfigOption"; id: string; category: string };
|
||||
render: "control" | "deferredUntilNativeOptionsAvailable";
|
||||
value: string | null;
|
||||
/** Set when a legacy key's value is being shown via fallback; callers
|
||||
* should add this key to hidden-keys to suppress the duplicate row. */
|
||||
legacyConsumedKey?: string;
|
||||
}
|
||||
| {
|
||||
kind: "maxOutputTokens" | "contextLimit" | "maxRounds";
|
||||
@@ -117,6 +123,40 @@ function valueFromEnv(config: GlobalAgentConfig, key: string) {
|
||||
return config.env_vars[key]?.trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single policy source for reading effort from a flat env-var map.
|
||||
*
|
||||
* Native key takes precedence; legacy key is the fallback only at
|
||||
* record/persona tiers (caller must pass `legacyKey` conditionally — pass
|
||||
* `null` at global/onboarding scope to enforce the tier boundary).
|
||||
*
|
||||
* Returns `{ value, legacyConsumed }` where:
|
||||
* - `value` — the normalized canonical value, or `null` if absent/invalid
|
||||
* - `legacyConsumed` — true iff `value` came from the legacy key
|
||||
*
|
||||
* Used by both `deriveAgentConfigFieldModel` (model layer) and
|
||||
* `HarnessNativeEffortFields` (component layer) so neither duplicates the
|
||||
* native-first / valid-legacy-fallback / normalize logic.
|
||||
*/
|
||||
export function resolveEffortFromEnv(
|
||||
envVars: Record<string, string>,
|
||||
nativeKey: string,
|
||||
legacyKey: string | null,
|
||||
acceptedValues: readonly string[] | null,
|
||||
): { value: string | null; legacyConsumed: boolean } {
|
||||
const nativeRaw = envVars[nativeKey]?.trim() || null;
|
||||
const legacyRaw = legacyKey ? envVars[legacyKey]?.trim() || null : null;
|
||||
const nativeValue = nativeRaw
|
||||
? (normalizeEffortValue(nativeRaw, acceptedValues) ?? null)
|
||||
: null;
|
||||
const legacyValue = legacyRaw
|
||||
? (normalizeEffortValue(legacyRaw, acceptedValues) ?? null)
|
||||
: null;
|
||||
const value = nativeValue ?? legacyValue;
|
||||
const legacyConsumed = !nativeValue && !!legacyValue;
|
||||
return { value, legacyConsumed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the numeric descriptor set for a runtime from catalog fields.
|
||||
*
|
||||
@@ -204,19 +244,47 @@ export function deriveAgentConfigFieldModel({
|
||||
});
|
||||
|
||||
if (runtime?.thinkingEnvVar) {
|
||||
const nativeKey = runtime.thinkingEnvVar;
|
||||
const isBuzzAgent = runtime.id === "buzz-agent";
|
||||
// Legacy fallback applies only at record/persona tiers (scope "definition" or
|
||||
// "instance") — mirrors spawn's effort_tier_alias(global_tier=true) policy.
|
||||
// At global/onboarding scope, legacy is buzz-agent's native key (Delta 5) and
|
||||
// must never be consumed as Goose effort.
|
||||
const isRecordOrPersonaScope =
|
||||
scope === "definition" || scope === "instance";
|
||||
const legacyKey =
|
||||
!isBuzzAgent && isRecordOrPersonaScope
|
||||
? BUZZ_AGENT_THINKING_EFFORT
|
||||
: null;
|
||||
const acceptedValues = isBuzzAgent
|
||||
? null
|
||||
: (runtime.acceptedEffortValues ?? null);
|
||||
// resolveEffortFromEnv is the single policy source for effort reads —
|
||||
// normalizes, applies native-first / valid-legacy-fallback, and reports
|
||||
// which key was consumed. HarnessNativeEffortFields calls the same fn.
|
||||
const { value: effortValue, legacyConsumed } = resolveEffortFromEnv(
|
||||
config.env_vars,
|
||||
nativeKey,
|
||||
legacyKey,
|
||||
acceptedValues,
|
||||
);
|
||||
fields.push({
|
||||
kind: "effort",
|
||||
optionSource:
|
||||
runtime.id === "buzz-agent"
|
||||
? "buzzAgentCatalog"
|
||||
optionSource: isBuzzAgent
|
||||
? "buzzAgentCatalog"
|
||||
: runtime.acceptedEffortValues
|
||||
? "harnessNative"
|
||||
: "legacyProviderModelCatalog",
|
||||
currentPersistence: {
|
||||
kind: "envVar",
|
||||
key: BUZZ_AGENT_THINKING_EFFORT,
|
||||
key: nativeKey,
|
||||
},
|
||||
targetApplication: { kind: "envVar", key: runtime.thinkingEnvVar },
|
||||
targetApplication: { kind: "envVar", key: nativeKey },
|
||||
render: "control",
|
||||
value: valueFromEnv(config, BUZZ_AGENT_THINKING_EFFORT),
|
||||
value: effortValue,
|
||||
legacyConsumedKey: legacyConsumed
|
||||
? BUZZ_AGENT_THINKING_EFFORT
|
||||
: undefined,
|
||||
});
|
||||
} else if (runtime?.id === "claude") {
|
||||
fields.push({
|
||||
@@ -291,9 +359,7 @@ export function getRenderableEffortField(
|
||||
* Per-surface consequences (assuming standard descriptor sets):
|
||||
* - Global: effort key + numeric keys rendered by the descriptors
|
||||
* - Per-agent buzz-agent: effort key + 3 numeric keys
|
||||
* - Per-agent Goose: 2 numeric keys only — Goose effort (BUZZ_AGENT_THINKING_EFFORT)
|
||||
* stays a visible generic env row because no effort control renders per-agent
|
||||
* for Goose (effort migration is out of scope)
|
||||
* - Per-agent Goose: effort key (GOOSE_THINKING_EFFORT) + 2 numeric keys
|
||||
*/
|
||||
export function structuredEnvKeys(
|
||||
renderedDescriptors: AgentConfigFieldDescriptor[],
|
||||
|
||||
@@ -356,7 +356,7 @@ test("test_foreign_entry_with_no_local_copy_stays_unselected", () => {
|
||||
BOB,
|
||||
);
|
||||
|
||||
assert.equal(personas[0].id, "catalog:" + ALICE + ":reviewer");
|
||||
assert.equal(personas[0].id, `catalog:${ALICE}:reviewer`);
|
||||
assert.equal(personas[0].isActive, false);
|
||||
});
|
||||
|
||||
@@ -377,7 +377,7 @@ test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => {
|
||||
ALICE,
|
||||
);
|
||||
|
||||
assert.equal(personas[0].id, "catalog:" + BOB + ":reviewer");
|
||||
assert.equal(personas[0].id, `catalog:${BOB}:reviewer`);
|
||||
assert.equal(personas[0].isActive, false);
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
filterBakedGenericRows,
|
||||
} from "@/features/agents/lib/agentConfigCore";
|
||||
import {
|
||||
bakedStructuredKeys,
|
||||
getBakedProviderInheritLabel,
|
||||
getGlobalModelFallback,
|
||||
} from "@/features/agents/ui/bakedEnvHelpers";
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
EffortSelectField,
|
||||
NumericTuningFields,
|
||||
useEffortAutoClear,
|
||||
applyHarnessNativeEffortChange,
|
||||
type NumericDescriptor,
|
||||
} from "@/features/agents/ui/buzzAgentModelTuningFields";
|
||||
import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup";
|
||||
@@ -70,12 +72,6 @@ export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = {
|
||||
preferred_runtime: null,
|
||||
};
|
||||
|
||||
const BAKED_STRUCTURED_KEYS = new Set([
|
||||
"BUZZ_AGENT_PROVIDER",
|
||||
"BUZZ_AGENT_MODEL",
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
]);
|
||||
|
||||
const PROGRESSIVE_FIELDS_TRANSITION = {
|
||||
duration: 0.22,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
@@ -85,14 +81,7 @@ type AgentConfigDisclosure =
|
||||
| "onboarding-essential"
|
||||
| "progressive-defaults";
|
||||
|
||||
// Canonical behaviors (PR 2 flag cleanup). These were per-surface props;
|
||||
// onboarding's values won every call and are now the only behavior:
|
||||
// - auto-select a valid model when the provider changes
|
||||
// - keep the model select usable during discovery
|
||||
// - preserve credential env vars across provider switches (the abandoned
|
||||
// provider's key stays in env_vars — visible/deletable under Advanced)
|
||||
// - require a provider before model/effort are editable (no saveable
|
||||
// invalid state — design principle #4)
|
||||
// Canonical behaviors (PR 2 flag cleanup — baked-in for all surfaces):
|
||||
const autoSelectModelOnProviderChange = true;
|
||||
const disableModelSelectDuringDiscovery = false;
|
||||
const preserveCredentialEnvVarsOnProviderChange = true;
|
||||
@@ -254,6 +243,7 @@ export function AgentConfigFields({
|
||||
effortField?.currentPersistence.kind === "envVar"
|
||||
? effortField.currentPersistence.key
|
||||
: null;
|
||||
const isHarnessNativeEffort = effortField?.optionSource === "harnessNative";
|
||||
|
||||
const numericDescriptors = fieldModel.fields.filter(
|
||||
(d): d is NumericDescriptor =>
|
||||
@@ -262,10 +252,15 @@ export function AgentConfigFields({
|
||||
d.kind === "maxRounds") &&
|
||||
d.render === "control",
|
||||
);
|
||||
const allStructuredKeys = structuredEnvKeys([
|
||||
...(effortField ? [effortField] : []),
|
||||
...numericDescriptors,
|
||||
]);
|
||||
const allStructuredKeys = React.useMemo(() => {
|
||||
const keys = structuredEnvKeys([
|
||||
...(effortField ? [effortField] : []),
|
||||
...numericDescriptors,
|
||||
]);
|
||||
if (effortField?.legacyConsumedKey)
|
||||
keys.push(effortField.legacyConsumedKey);
|
||||
return keys;
|
||||
}, [effortField, numericDescriptors]);
|
||||
const bakedEnvMap = Object.fromEntries(bakedEnv.map((e) => [e.key, e.value]));
|
||||
const bakedProvider = React.useMemo(
|
||||
() => bakedEnv.find((e) => e.key === "BUZZ_AGENT_PROVIDER")?.value ?? null,
|
||||
@@ -286,8 +281,6 @@ export function AgentConfigFields({
|
||||
const modelField = fieldModel.fields.find(
|
||||
(field) => field.kind === "model" && field.render === "control",
|
||||
);
|
||||
// CLI-login harnesses apply this setting through ACP rather than an env var
|
||||
// and provide their own default when no model override is persisted.
|
||||
const modelIsOptional = modelField?.targetApplication.kind === "acpNative";
|
||||
const modelIsValid =
|
||||
modelIsOptional ||
|
||||
@@ -295,16 +288,18 @@ export function AgentConfigFields({
|
||||
fallbackModel !== null;
|
||||
const bakedEffort = React.useMemo(
|
||||
() =>
|
||||
bakedEnv.find((e) => e.key === BUZZ_AGENT_THINKING_EFFORT)?.value ?? null,
|
||||
[bakedEnv],
|
||||
bakedEnv.find(
|
||||
(e) => e.key === (effortPersistenceKey ?? BUZZ_AGENT_THINKING_EFFORT),
|
||||
)?.value ?? null,
|
||||
[bakedEnv, effortPersistenceKey],
|
||||
);
|
||||
const bakedGenericRows = React.useMemo<readonly InheritedEnvRow[]>(
|
||||
() =>
|
||||
filterBakedGenericRows(bakedEnv, [
|
||||
...BAKED_STRUCTURED_KEYS,
|
||||
...bakedStructuredKeys(effortPersistenceKey ?? undefined),
|
||||
...allStructuredKeys,
|
||||
]),
|
||||
[bakedEnv, allStructuredKeys],
|
||||
[bakedEnv, allStructuredKeys, effortPersistenceKey],
|
||||
);
|
||||
|
||||
const providerValue = providerFieldVisible ? (config.provider ?? "") : "";
|
||||
@@ -383,20 +378,12 @@ export function AgentConfigFields({
|
||||
showCustomModelOption,
|
||||
});
|
||||
|
||||
// Mount-time healing policy: onboarding page 4 edits the root config during
|
||||
// first-run (no higher layers to inherit from), so acting on open is safe
|
||||
// and intentional there — it heals stale state and picks a valid model.
|
||||
// Evergreen surfaces (Settings, dialogs) edit saved data that may pair with
|
||||
// higher layers (see PR #2148 review thread), so they only act after the
|
||||
// user explicitly edits the provider in this session.
|
||||
// Mount-time healing (onboarding page 4 only). Evergreen surfaces wait for
|
||||
// explicit provider edit (see PR #2148).
|
||||
const healOnMount =
|
||||
fieldModel.dependentValuePolicy.onCatalogMismatch === "onboardingCleanup";
|
||||
const userEditedProviderRef = React.useRef(false);
|
||||
// Advanced visibility is user-controlled. Provider changes can add required
|
||||
// rows, but must not open this section without an explicit toggle click.
|
||||
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
||||
// Read inside effects via ref so biome's exhaustive-deps stays honest:
|
||||
// refs are stable, and healOnMount is captured at declaration.
|
||||
const mayMutateDependentFieldsRef = React.useRef(false);
|
||||
mayMutateDependentFieldsRef.current =
|
||||
healOnMount || userEditedProviderRef.current;
|
||||
@@ -437,15 +424,12 @@ export function AgentConfigFields({
|
||||
const currentEffortForAutoClear = effortPersistenceKey
|
||||
? (config.env_vars[effortPersistenceKey] ?? "")
|
||||
: "";
|
||||
const effortValidForAutoClear = isHarnessNativeEffort
|
||||
? (selectedRuntime?.acceptedEffortValues ?? [])
|
||||
: null;
|
||||
|
||||
// When the selected harness changes outside this component (Back → setup
|
||||
// page → choose a different harness → Next), the saved model can belong to
|
||||
// the old harness. In onboarding, heal that stale value as soon as the new
|
||||
// harness catalog proves it is unsupported; otherwise a Codex id like
|
||||
// `gpt-5.5[low]` appears as a Claude Code custom model.
|
||||
// Also clear when the Model control is omitted after a confirmed successful
|
||||
// empty catalog — never while discovery failed/unavailable (transient
|
||||
// failures must not erase saved model/effort).
|
||||
// Stale-model heal: onboarding heals on mount; evergreen waits for provider edit.
|
||||
// See PR #2148 for why evergreen uses a ref-gated mutation policy.
|
||||
React.useEffect(() => {
|
||||
if (!healOnMount) return;
|
||||
const currentModel = (config.model ?? "").trim();
|
||||
@@ -462,7 +446,9 @@ export function AgentConfigFields({
|
||||
if (!catalogMiss && !omittedAfterSuccessfulEmpty) return;
|
||||
|
||||
const nextEnvVars = { ...config.env_vars };
|
||||
if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey];
|
||||
if (effortPersistenceKey && !isHarnessNativeEffort)
|
||||
delete nextEnvVars[effortPersistenceKey];
|
||||
if (!isHarnessNativeEffort) delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT];
|
||||
onCustomModelEditingChange(false);
|
||||
onConfigChange({ ...config, env_vars: nextEnvVars, model: null });
|
||||
}, [
|
||||
@@ -476,28 +462,24 @@ export function AgentConfigFields({
|
||||
onCustomModelEditingChange,
|
||||
healOnMount,
|
||||
effortPersistenceKey,
|
||||
isHarnessNativeEffort,
|
||||
]);
|
||||
|
||||
// Orphan-model clearing follows the mount-time healing policy above: the
|
||||
// backend resolves provider and model independently across layers
|
||||
// (agent → definition → global), so a saved global model WITHOUT a global
|
||||
// provider can be a deliberate, working pattern (provider supplied by a
|
||||
// higher layer). Clearing it on page-open in evergreen surfaces silently
|
||||
// breaks that agent on its next restart — see PR #2148 review thread.
|
||||
// Onboarding heals on open by design (discriminating spec: "gates stale
|
||||
// saved model and effort until provider selection").
|
||||
// Orphan-model clear: same ref-gated policy as mount-time heal (see PR #2148).
|
||||
React.useEffect(() => {
|
||||
if (!mayMutateDependentFieldsRef.current) return;
|
||||
if (!dependentFieldsDisabled) return;
|
||||
if (
|
||||
(config.model ?? "").trim().length === 0 &&
|
||||
currentEffortForAutoClear.length === 0
|
||||
(currentEffortForAutoClear.length === 0 || isHarnessNativeEffort)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEnvVars = { ...config.env_vars };
|
||||
if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey];
|
||||
if (effortPersistenceKey && !isHarnessNativeEffort)
|
||||
delete nextEnvVars[effortPersistenceKey];
|
||||
if (!isHarnessNativeEffort) delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT];
|
||||
onCustomModelEditingChange(false);
|
||||
onConfigChange({ ...config, env_vars: nextEnvVars, model: null });
|
||||
}, [
|
||||
@@ -507,17 +489,20 @@ export function AgentConfigFields({
|
||||
onConfigChange,
|
||||
onCustomModelEditingChange,
|
||||
effortPersistenceKey,
|
||||
isHarnessNativeEffort,
|
||||
]);
|
||||
const { validValues: effortValidForAutoClear } = getProviderEffortConfig(
|
||||
const { validValues: providerModelEffortValid } = getProviderEffortConfig(
|
||||
config.provider ?? "",
|
||||
config.model ?? "",
|
||||
);
|
||||
useEffortAutoClear({
|
||||
currentEffort: currentEffortForAutoClear,
|
||||
effortValid: effortValidForAutoClear,
|
||||
currentEffort: isHarnessNativeEffort ? "" : currentEffortForAutoClear,
|
||||
effortValid: effortValidForAutoClear ?? providerModelEffortValid,
|
||||
onClear: () => {
|
||||
const nextEnvVars = { ...config.env_vars };
|
||||
if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey];
|
||||
if (!isHarnessNativeEffort)
|
||||
delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT];
|
||||
onConfigChange({ ...config, env_vars: nextEnvVars });
|
||||
},
|
||||
});
|
||||
@@ -634,9 +619,11 @@ export function AgentConfigFields({
|
||||
: implicitEffortProvider;
|
||||
const { validValues: effortValid, defaultValue: effortDefault } =
|
||||
getProviderEffortConfig(effortProvider, config.model ?? "");
|
||||
const currentEffort = effortPersistenceKey
|
||||
? (config.env_vars[effortPersistenceKey] ?? "")
|
||||
: "";
|
||||
const currentEffort = isHarnessNativeEffort
|
||||
? (effortField?.value ?? "")
|
||||
: effortPersistenceKey
|
||||
? (config.env_vars[effortPersistenceKey] ?? "")
|
||||
: "";
|
||||
const effortFieldVisible = showEffortField && effortField !== undefined;
|
||||
|
||||
const progressiveDefaults = disclosure === "progressive-defaults";
|
||||
@@ -848,42 +835,60 @@ export function AgentConfigFields({
|
||||
{effortFieldVisible ? (
|
||||
<div className={blockClassName}>
|
||||
<EffortSelectField
|
||||
canonicalValues={selectedRuntime?.acceptedEffortValues ?? undefined}
|
||||
currentEffort={dependentFieldsDisabled ? "" : currentEffort}
|
||||
disabled={dependentFieldsDisabled}
|
||||
effortDefault={isHarnessNativeEffort ? null : effortDefault}
|
||||
effortValid={
|
||||
isHarnessNativeEffort
|
||||
? (selectedRuntime?.acceptedEffortValues ?? [])
|
||||
: effortValid
|
||||
}
|
||||
emptyOptionLabel={
|
||||
// Semantic, not copy: onboarding-essential hides inheritance
|
||||
// concepts (first-run users pick, they don't inherit), so the
|
||||
// zero option is a plain placeholder. Full disclosure leaves
|
||||
// this unset so EffortSelectField computes the inherit/default
|
||||
// label ("Default (medium)", "Inherit (high)", …).
|
||||
disclosure === "onboarding-essential"
|
||||
!isHarnessNativeEffort && disclosure === "onboarding-essential"
|
||||
? "Select effort level"
|
||||
: undefined
|
||||
}
|
||||
effortDefault={effortDefault}
|
||||
effortValid={effortValid}
|
||||
fieldClassName={unstyled ? fieldClassName : undefined}
|
||||
htmlFor="global-agent-thinking-effort"
|
||||
inheritFallbackLabel={
|
||||
effortDefault !== null ? `Default (${effortDefault})` : undefined
|
||||
!isHarnessNativeEffort && effortDefault !== null
|
||||
? `Default (${effortDefault})`
|
||||
: undefined
|
||||
}
|
||||
inheritedEffort={bakedEffort ?? undefined}
|
||||
label="Effort"
|
||||
labelClassName={fieldLabelClassName}
|
||||
onChange={(value) => {
|
||||
const nextEnvVars = { ...config.env_vars };
|
||||
if (value === "") {
|
||||
if (effortPersistenceKey)
|
||||
delete nextEnvVars[effortPersistenceKey];
|
||||
} else {
|
||||
if (effortPersistenceKey)
|
||||
nextEnvVars[effortPersistenceKey] = value;
|
||||
if (isHarnessNativeEffort) {
|
||||
if (!effortPersistenceKey) return;
|
||||
onConfigChange({
|
||||
...config,
|
||||
env_vars: applyHarnessNativeEffortChange(
|
||||
config.env_vars,
|
||||
effortPersistenceKey,
|
||||
null,
|
||||
value,
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
onConfigChange({ ...config, env_vars: nextEnvVars });
|
||||
const next = { ...config.env_vars };
|
||||
if (value === "") {
|
||||
if (effortPersistenceKey) delete next[effortPersistenceKey];
|
||||
delete next[BUZZ_AGENT_THINKING_EFFORT];
|
||||
} else {
|
||||
if (effortPersistenceKey) next[effortPersistenceKey] = value;
|
||||
if (effortPersistenceKey !== BUZZ_AGENT_THINKING_EFFORT)
|
||||
delete next[BUZZ_AGENT_THINKING_EFFORT];
|
||||
}
|
||||
onConfigChange({ ...config, env_vars: next });
|
||||
}}
|
||||
placeholderClassName={placeholderClassName}
|
||||
selectClassName={selectClassName}
|
||||
showUnavailableOptions={showUnavailableEffortOptions}
|
||||
showUnavailableOptions={
|
||||
isHarnessNativeEffort ? undefined : showUnavailableEffortOptions
|
||||
}
|
||||
testId="global-agent-thinking-effort-select"
|
||||
useCustomSelect={useCustomSelect}
|
||||
/>
|
||||
|
||||
@@ -154,18 +154,14 @@ export function AgentDefinitionDialog({
|
||||
const [behaviorDraft, setBehaviorDraft] = React.useState(
|
||||
emptyPersonaBehaviorDraft,
|
||||
);
|
||||
// The seed the draft is diffed against at submit: an untouched quad
|
||||
// submits no behavior group, keeping unrelated edits hash-quiet.
|
||||
// Draft seed; untouched quad submits no behavior group (hash-quiet).
|
||||
const behaviorSeedRef = React.useRef(emptyPersonaBehaviorDraft);
|
||||
// Tracks when the runtime was auto-seeded by the default-runtime effect in
|
||||
// edit mode (i.e. the user never explicitly chose a runtime). Used to omit
|
||||
// the seeded runtime from the submit payload for builtin definitions whose
|
||||
// canonical runtime is null — the sync would revert it anyway.
|
||||
const isRuntimeAutoSeededRef = React.useRef(false);
|
||||
// Guards the seeding effect so it fires at most once per dialog-open.
|
||||
// Without this, clearing runtime back to "" via "No preference" would re-
|
||||
// trigger the effect (the `runtime` dep would pass the length guard) and
|
||||
// snap the dropdown back to the default — an edit-mode regression.
|
||||
// Guards seeding effect; fires at most once per open (prevents re-trigger on clear).
|
||||
const hasSeededForOpenRef = React.useRef(false);
|
||||
const [showAdvancedFields, setShowAdvancedFields] = React.useState(false);
|
||||
const [isAvatarUploadPending, setIsAvatarUploadPending] =
|
||||
@@ -179,7 +175,12 @@ export function AgentDefinitionDialog({
|
||||
model: inheritedModelDefault,
|
||||
},
|
||||
inheritedEnvVars: inheritedEnvVarsForAdvanced,
|
||||
} = useAgentDialogDefaults({ open });
|
||||
} = useAgentDialogDefaults({
|
||||
open,
|
||||
nativeEffortKey: runtimes.find((r) => r.id === runtime)?.thinkingEnvVar,
|
||||
acceptedEffortValues:
|
||||
runtimes.find((r) => r.id === runtime)?.acceptedEffortValues ?? null,
|
||||
});
|
||||
const defaultRuntime = React.useMemo(
|
||||
() => getDefaultPersonaRuntime(runtimes, globalConfig.preferred_runtime),
|
||||
[globalConfig.preferred_runtime, runtimes],
|
||||
@@ -445,13 +446,10 @@ export function AgentDefinitionDialog({
|
||||
runtimeFileConfig,
|
||||
],
|
||||
);
|
||||
// requiredEnvKeys: the gate already handles baked-, global-, and file-
|
||||
// satisfied keys so no further filtering is needed.
|
||||
// requiredEnvKeys: gate handles baked/global/file-satisfied keys; no extra filtering.
|
||||
const { requiredEnvKeys } = localModeGate;
|
||||
const localModeSatisfied = localModeGate.satisfied;
|
||||
// Effective provider: agent value → global fallback → file fallback.
|
||||
// Mirrors the chain inside computeLocalModeGate so model-option scoping and
|
||||
// model requiredness are consistent with the readiness gate.
|
||||
// Effective provider: agent value → global fallback → file fallback (mirrors gate chain).
|
||||
const fileProvider = runtimeFileConfig?.provider?.trim() ?? "";
|
||||
const effectiveProvider =
|
||||
trimmedProvider || inheritedProviderDefault.value || fileProvider;
|
||||
@@ -522,9 +520,7 @@ export function AgentDefinitionDialog({
|
||||
isCustomProviderEditing,
|
||||
modelFieldVisible,
|
||||
open,
|
||||
// Gate provider by runtime: runtimes that don't support LLM provider
|
||||
// selection (codex, claude) must not inherit the global provider — doing
|
||||
// so causes them to discover models from the wrong provider.
|
||||
// Gate provider by runtime: codex/claude must not inherit global provider.
|
||||
provider: runtimeSupportsLlmProviderSelection(runtime)
|
||||
? effectiveProvider
|
||||
: "",
|
||||
@@ -683,6 +679,10 @@ export function AgentDefinitionDialog({
|
||||
nextRuntime.trim().length > 0 &&
|
||||
runtimeSupportsLlmProviderSelection(nextRuntime),
|
||||
lockedRuntimeReset: "full",
|
||||
previousRuntimeNativeEffortKey:
|
||||
runtimes.find((r) => r.id === runtime)?.thinkingEnvVar ?? null,
|
||||
nextRuntimeNativeEffortKey:
|
||||
runtimes.find((r) => r.id === nextRuntime)?.thinkingEnvVar ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ import { useRequiredCredentialState } from "./useRequiredCredentialState";
|
||||
import { CreateAgentRespondToField } from "./RespondToField";
|
||||
import { RunOnSummarySection } from "./RunOnSummarySection";
|
||||
import { PersonaDropdownField } from "./PersonaDropdownField";
|
||||
import { ALL_KNOWN_EFFORT_KEYS } from "./buzzAgentConfig";
|
||||
import {
|
||||
MODEL_DISCOVERY_LOADING_VALUE,
|
||||
usePersonaModelDiscovery,
|
||||
@@ -266,10 +267,8 @@ export function AgentInstanceEditDialog({
|
||||
return runtimeSupportsLlmProviderSelection(matched?.id ?? "");
|
||||
}, [runtimes, originalAgentCommand]);
|
||||
|
||||
// The runtime id active after submit. Inheriting resolves from the LINKED PERSONA's runtime
|
||||
// (that is what runs once the override is cleared, not the current override).
|
||||
// Falls back to dual-match (command path, then id) when no persona or its runtime is unset.
|
||||
// This single prospective id feeds BOTH the block-save gate and submit so they always agree.
|
||||
// Post-submit effective runtime: from pinned selection, persona runtime, or command/id match.
|
||||
// Feeds both block-save gate and submit so they always agree.
|
||||
const prospectiveRuntimeId = React.useMemo(() => {
|
||||
if (!inheritHarness) {
|
||||
return selectedRuntime?.id ?? selectedRuntimeId;
|
||||
@@ -356,6 +355,13 @@ export function AgentInstanceEditDialog({
|
||||
personaModel: linkedPersona?.model ?? null,
|
||||
envVars,
|
||||
personaEnvVars: inheritedEnvVars,
|
||||
// Strip harness-native effort keys from the persona layer on inherit-transition.
|
||||
// Effort inherits at spawn (persona tier, not via record materialization — Delta 5).
|
||||
// Gated on thinkingEnvVar, so applies to all runtimes including buzz-agent: a
|
||||
// pinned→inherit transition no longer materializes native effort into the record.
|
||||
excludePersonaEnvKeys: prospectiveRuntime?.thinkingEnvVar
|
||||
? [prospectiveRuntime.thinkingEnvVar, ...ALL_KNOWN_EFFORT_KEYS]
|
||||
: undefined,
|
||||
}),
|
||||
[
|
||||
inheritHarness,
|
||||
@@ -366,6 +372,7 @@ export function AgentInstanceEditDialog({
|
||||
linkedPersona?.model,
|
||||
envVars,
|
||||
inheritedEnvVars,
|
||||
prospectiveRuntime?.thinkingEnvVar,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -376,7 +383,12 @@ export function AgentInstanceEditDialog({
|
||||
model: inheritedModelDefault,
|
||||
},
|
||||
inheritedEnvVars: inheritedEnvVarsForAdvanced,
|
||||
} = useAgentDialogDefaults({ inheritedEnvVars, open });
|
||||
} = useAgentDialogDefaults({
|
||||
inheritedEnvVars,
|
||||
open,
|
||||
nativeEffortKey: prospectiveRuntime?.thinkingEnvVar,
|
||||
acceptedEffortValues: prospectiveRuntime?.acceptedEffortValues ?? null,
|
||||
});
|
||||
|
||||
// Runtime/provider-required credential state for the PROSPECTIVE post-submit runtime.
|
||||
// globalProvider/globalEnvVars: fallback for empty per-agent provider; keys satisfied globally don't block Save.
|
||||
@@ -393,12 +405,8 @@ export function AgentInstanceEditDialog({
|
||||
|
||||
const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open });
|
||||
|
||||
// Merge global env as the base layer so credential keys satisfied via global
|
||||
// config (e.g. ANTHROPIC_API_KEY) are available to model discovery. Use
|
||||
// `inheritedSubmission.envVars` (the same snapshot the credential gate
|
||||
// validates) rather than raw `envVars`, so an inherit-transition that layers
|
||||
// in persona env vars is reflected in discovery. Agent-local env takes
|
||||
// precedence, matching the agent → global → file spawn-path precedence.
|
||||
// Merge global env as base for model discovery. Uses the credential-gate snapshot
|
||||
// so inherit-transitions reflect persona env vars. Agent-local env wins.
|
||||
const envVarsForDiscovery = React.useMemo(
|
||||
() => ({ ...globalConfig.env_vars, ...inheritedSubmission.envVars }),
|
||||
[globalConfig.env_vars, inheritedSubmission.envVars],
|
||||
@@ -421,11 +429,7 @@ export function AgentInstanceEditDialog({
|
||||
selectedRuntime,
|
||||
});
|
||||
|
||||
// D2: derive advancedRequiredEnvKeys for EnvVarsEditor display.
|
||||
// The full requiredEnvKeys/requiredEnvKeyMissing continue driving Save gating.
|
||||
// D2/D3: the top-level API key owns display, while the readiness gate keeps
|
||||
// the complete required-key list. The effective snapshot covers persona
|
||||
// inheritance during an instance inherit transition.
|
||||
// API key env var for display (D2/D3). Save gate uses full requiredEnvKeys.
|
||||
const providerApiKeyEnvVar = getProviderApiKeyEnvVar(effectiveProvider);
|
||||
const personaSatisfied =
|
||||
providerApiKeyEnvVar != null &&
|
||||
@@ -490,6 +494,18 @@ export function AgentInstanceEditDialog({
|
||||
setEnvVars(next.envVars);
|
||||
}
|
||||
|
||||
/** Pin↔inherit toggle: clear all effort keys (Delta-3 record-scope clear). */
|
||||
function handleInheritHarnessChange(nextInherit: boolean) {
|
||||
setInheritHarness(nextInherit);
|
||||
if (prospectiveRuntime?.thinkingEnvVar) {
|
||||
setEnvVars((prev: EnvVarsValue) => {
|
||||
const next = { ...prev };
|
||||
for (const key of ALL_KNOWN_EFFORT_KEYS) delete next[key];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleRuntimeDropdownChange(nextValue: string) {
|
||||
const action = runtimeDropdownAction(nextValue);
|
||||
if (action.kind === "add-custom-harness") {
|
||||
@@ -539,6 +555,10 @@ export function AgentInstanceEditDialog({
|
||||
nextRuntime?.id ?? nextRuntimeId,
|
||||
),
|
||||
lockedRuntimeReset: "full",
|
||||
previousRuntimeNativeEffortKey:
|
||||
runtimes.find((r) => r.id === previousRuntimeId)?.thinkingEnvVar ??
|
||||
null,
|
||||
nextRuntimeNativeEffortKey: nextRuntime?.thinkingEnvVar ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -634,14 +654,9 @@ export function AgentInstanceEditDialog({
|
||||
agentCommandOverride: agent.agentCommandOverride ?? null,
|
||||
});
|
||||
|
||||
// Classify the effective post-submit runtime's provider capability as a
|
||||
// tri-state: "capable" persists the provider, "locked" clears it (only
|
||||
// when we KNOW it's provider-locked, e.g. Claude), "unknown" OMITS it so a
|
||||
// transient/custom state never becomes a destructive write. Resolved
|
||||
// STATICALLY (by id) so a not-yet-loaded catalog can't misclassify a known
|
||||
// runtime as "unknown" — see resolveRuntimeProviderCapability. The runtime
|
||||
// id is the shared prospectiveRuntimeId, so submit and the block-save gate
|
||||
// always agree on which runtime is being saved.
|
||||
// Provider capability: "capable" persists, "locked" clears, "unknown" omits.
|
||||
// Resolved statically so unloaded catalog can't misclassify. Shared id ensures
|
||||
// submit and block-save gate agree.
|
||||
const providerRuntimeCapability = resolveRuntimeProviderCapability(
|
||||
prospectiveRuntimeId,
|
||||
runtimeSupportsLlmProviderSelection(prospectiveRuntimeId),
|
||||
@@ -662,10 +677,7 @@ export function AgentInstanceEditDialog({
|
||||
? acpCommand.trim()
|
||||
: undefined,
|
||||
agentCommand: agentCommandUpdate,
|
||||
// A non-inheriting selection is a deliberate pin — signal it so the
|
||||
// backend preserves a Custom/runtime command even when it maps to the
|
||||
// linked persona's own runtime (otherwise it would be dropped back to
|
||||
// inherit). Omitted (falsy) when inheriting or on a name-only edit.
|
||||
// Non-inheriting = explicit pin; preserves Custom commands vs linked persona. Omitted when inheriting.
|
||||
harnessOverride:
|
||||
agentCommandUpdate != null ? !inheritHarness : undefined,
|
||||
agentArgs:
|
||||
@@ -689,11 +701,7 @@ export function AgentInstanceEditDialog({
|
||||
: normalizedModel !== (agent.model ?? null)
|
||||
? normalizedModel
|
||||
: undefined,
|
||||
// Tri-state provider persistence keyed on providerRuntimeCapability:
|
||||
// "capable" → persist: value if changed, omit if unchanged.
|
||||
// "locked" → clear: send null if provider was set, else omit.
|
||||
// "unknown" → omit always (never send null for a transient state).
|
||||
// llmProviderFieldVisible is for UX visibility only; not used here.
|
||||
// Tri-state provider: "capable"→persist if changed, "locked"→clear, "unknown"→omit.
|
||||
provider:
|
||||
linkedPersona != null
|
||||
? undefined
|
||||
@@ -710,12 +718,7 @@ export function AgentInstanceEditDialog({
|
||||
? undefined
|
||||
: submitEnvVars,
|
||||
respondTo: respondTo !== agent.respondTo ? respondTo : undefined,
|
||||
// The allowlist is preserved across mode toggles in local UI state
|
||||
// (so a user can flip away from allowlist and back without losing
|
||||
// their entries), but we only send it on the wire when (a) it
|
||||
// actually changed, AND (b) the saved mode will need it. Sending
|
||||
// an allowlist while switching to a non-allowlist mode would be
|
||||
// harmless server-side, but it's noise in the persisted record.
|
||||
// Send allowlist only when changed AND saved mode is "allowlist".
|
||||
respondToAllowlist:
|
||||
respondTo === "allowlist" &&
|
||||
respondToAllowlist.join(",") !== agent.respondToAllowlist.join(",")
|
||||
@@ -735,10 +738,7 @@ export function AgentInstanceEditDialog({
|
||||
showAgentProfileSyncWarning(result.agent.name, result.profileSyncError);
|
||||
handleOpenChange(false);
|
||||
onUpdated?.(result.agent);
|
||||
// The auto-restart policy deliberately never fires for a stopped or
|
||||
// failing agent (a broken agent must not auto-loop), so an edit meant
|
||||
// to FIX one silently waits for a manual start. Offer that start
|
||||
// explicitly instead of relying on the user to know the policy.
|
||||
// Auto-restart doesn't fire for stopped/failing agents; offer manual start.
|
||||
if (!isManagedAgentActive(result.agent)) {
|
||||
const startedName = result.agent.name;
|
||||
toast(`${startedName} saved while stopped.`, {
|
||||
@@ -1204,7 +1204,7 @@ export function AgentInstanceEditDialog({
|
||||
onAgentArgsChange={setAgentArgs}
|
||||
onAutoRestartChange={setAutoRestartOnConfigChange}
|
||||
onEnvVarsChange={setEnvVars}
|
||||
onInheritHarnessChange={setInheritHarness}
|
||||
onInheritHarnessChange={handleInheritHarnessChange}
|
||||
onParallelismChange={setParallelism}
|
||||
onSystemPromptChange={setSystemPrompt}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { AgentPersona } from "@/shared/api/types";
|
||||
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
|
||||
import {
|
||||
BuzzAgentModelTuningFields,
|
||||
HarnessNativeEffortFields,
|
||||
NumericTuningFields,
|
||||
} from "./buzzAgentModelTuningFields";
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
deriveNumericDescriptors,
|
||||
structuredEnvKeys,
|
||||
type RuntimeCatalogStatus,
|
||||
resolveEffortFromEnv,
|
||||
} from "../lib/agentConfigCore";
|
||||
|
||||
export function EditAgentAdvancedFields({
|
||||
@@ -115,17 +117,50 @@ export function EditAgentAdvancedFields({
|
||||
[catalogStatus, selectedRuntime],
|
||||
);
|
||||
|
||||
// Harness-native effort: shown when the runtime declares a static canonical
|
||||
// effort vocabulary (e.g. Goose). The native key is hidden from the generic
|
||||
// env editor. The legacy key is hidden only when it was validly consumed.
|
||||
const harnessNativeEffort =
|
||||
catalogStatus === "ready"
|
||||
? (selectedRuntime?.acceptedEffortValues ?? null)
|
||||
: null;
|
||||
const harnessNativeEffortKey = selectedRuntime?.thinkingEnvVar ?? null;
|
||||
// EditAgentAdvancedFields operates at "instance" scope — legacy fallback applies.
|
||||
// resolveEffortFromEnv is the single policy source; the component reads the same fn.
|
||||
const legacyEffortConsumed = React.useMemo(() => {
|
||||
if (!harnessNativeEffort || !harnessNativeEffortKey) return false;
|
||||
return resolveEffortFromEnv(
|
||||
envVars,
|
||||
harnessNativeEffortKey,
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
harnessNativeEffort,
|
||||
).legacyConsumed;
|
||||
}, [envVars, harnessNativeEffort, harnessNativeEffortKey]);
|
||||
|
||||
// Build the effective hidden-key list: caller's secrets + effort key (when
|
||||
// rendered by BuzzAgentModelTuningFields) + numeric keys via structuredEnvKeys.
|
||||
// rendered by BuzzAgentModelTuningFields or HarnessNativeEffortFields) + numeric keys.
|
||||
const effectiveHiddenKeys = React.useMemo(
|
||||
() => [
|
||||
...hiddenEnvKeys,
|
||||
...(isBuzzAgentRuntime(modelTuningRuntimeId)
|
||||
? [BUZZ_AGENT_THINKING_EFFORT]
|
||||
: []),
|
||||
...(harnessNativeEffort && harnessNativeEffortKey
|
||||
? [
|
||||
harnessNativeEffortKey,
|
||||
...(legacyEffortConsumed ? [BUZZ_AGENT_THINKING_EFFORT] : []),
|
||||
]
|
||||
: []),
|
||||
...structuredEnvKeys(numericDescriptors),
|
||||
],
|
||||
[hiddenEnvKeys, modelTuningRuntimeId, numericDescriptors],
|
||||
[
|
||||
hiddenEnvKeys,
|
||||
modelTuningRuntimeId,
|
||||
harnessNativeEffort,
|
||||
harnessNativeEffortKey,
|
||||
legacyEffortConsumed,
|
||||
numericDescriptors,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -334,7 +369,7 @@ export function EditAgentAdvancedFields({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Effort-tuning knob — only shown for buzz-agent. */}
|
||||
{/* Effort-tuning knob — buzz-agent (catalog-based) or harness-native. */}
|
||||
{isBuzzAgentRuntime(modelTuningRuntimeId) ? (
|
||||
<BuzzAgentModelTuningFields
|
||||
envVars={envVars}
|
||||
@@ -351,6 +386,15 @@ export function EditAgentAdvancedFields({
|
||||
}}
|
||||
provider={provider}
|
||||
/>
|
||||
) : harnessNativeEffort && harnessNativeEffortKey ? (
|
||||
<HarnessNativeEffortFields
|
||||
acceptedEffortValues={harnessNativeEffort}
|
||||
envVars={envVars}
|
||||
inheritedEnvVars={inheritedEnvVars}
|
||||
legacyEnvKey={BUZZ_AGENT_THINKING_EFFORT}
|
||||
nativeEffortKey={harnessNativeEffortKey}
|
||||
onEnvVarsChange={onEnvVarsChange}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../lib/agentParallelism";
|
||||
import {
|
||||
BuzzAgentModelTuningFields,
|
||||
HarnessNativeEffortFields,
|
||||
NumericTuningFields,
|
||||
} from "./buzzAgentModelTuningFields";
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
deriveNumericDescriptors,
|
||||
structuredEnvKeys,
|
||||
type RuntimeCatalogStatus,
|
||||
resolveEffortFromEnv,
|
||||
} from "../lib/agentConfigCore";
|
||||
|
||||
export function PersonaAdvancedFields({
|
||||
@@ -92,15 +94,55 @@ export function PersonaAdvancedFields({
|
||||
[catalogStatus, selectedRuntime],
|
||||
);
|
||||
|
||||
// Harness-native effort: shown when the runtime declares a static canonical
|
||||
// effort vocabulary (e.g. Goose). The native key is hidden from the generic
|
||||
// env editor. The legacy key is hidden only when it was validly consumed —
|
||||
// i.e. the native key is absent, the legacy key is present, and its value
|
||||
// normalizes to a canonical form. Invalid legacy values stay visible as
|
||||
// advanced rows (plan v3 Delta 4 / ★ unconsumed-row visibility pin).
|
||||
const harnessNativeEffort =
|
||||
catalogStatus === "ready"
|
||||
? (selectedRuntime?.acceptedEffortValues ?? null)
|
||||
: null;
|
||||
const harnessNativeEffortKey = selectedRuntime?.thinkingEnvVar ?? null;
|
||||
// PersonaAdvancedFields operates at "definition" scope — legacy fallback applies.
|
||||
// resolveEffortFromEnv is the single policy source; the component reads the same fn.
|
||||
const legacyEffortConsumed = React.useMemo(() => {
|
||||
if (!harnessNativeEffort || !harnessNativeEffortKey) return false;
|
||||
return resolveEffortFromEnv(
|
||||
envVars,
|
||||
harnessNativeEffortKey,
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
harnessNativeEffort,
|
||||
).legacyConsumed;
|
||||
}, [envVars, harnessNativeEffort, harnessNativeEffortKey]);
|
||||
|
||||
const effectiveHiddenKeys = React.useMemo(
|
||||
() => [
|
||||
...hiddenEnvKeys,
|
||||
...(isBuzzAgentRuntime(modelTuningRuntimeId)
|
||||
? [BUZZ_AGENT_THINKING_EFFORT]
|
||||
: []),
|
||||
// When the harness-native effort control is shown, hide the native key
|
||||
// from generic env editor. Also hide the legacy key when it was validly
|
||||
// consumed (i.e. it provided the displayed effort value); leave it visible
|
||||
// when invalid or not consumed.
|
||||
...(harnessNativeEffort && harnessNativeEffortKey
|
||||
? [
|
||||
harnessNativeEffortKey,
|
||||
...(legacyEffortConsumed ? [BUZZ_AGENT_THINKING_EFFORT] : []),
|
||||
]
|
||||
: []),
|
||||
...structuredEnvKeys(numericDescriptors),
|
||||
],
|
||||
[hiddenEnvKeys, modelTuningRuntimeId, numericDescriptors],
|
||||
[
|
||||
hiddenEnvKeys,
|
||||
modelTuningRuntimeId,
|
||||
harnessNativeEffort,
|
||||
harnessNativeEffortKey,
|
||||
legacyEffortConsumed,
|
||||
numericDescriptors,
|
||||
],
|
||||
);
|
||||
return (
|
||||
<div className="space-y-5 pt-2">
|
||||
@@ -222,7 +264,7 @@ export function PersonaAdvancedFields({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Effort-tuning knob — only shown for buzz-agent. */}
|
||||
{/* Effort-tuning knob — buzz-agent (catalog-based) or harness-native. */}
|
||||
{isBuzzAgentRuntime(modelTuningRuntimeId) ? (
|
||||
<BuzzAgentModelTuningFields
|
||||
envVars={envVars}
|
||||
@@ -239,6 +281,15 @@ export function PersonaAdvancedFields({
|
||||
}}
|
||||
provider={provider}
|
||||
/>
|
||||
) : harnessNativeEffort && harnessNativeEffortKey ? (
|
||||
<HarnessNativeEffortFields
|
||||
acceptedEffortValues={harnessNativeEffort}
|
||||
envVars={envVars}
|
||||
inheritedEnvVars={inheritedEnvVars}
|
||||
legacyEnvKey={BUZZ_AGENT_THINKING_EFFORT}
|
||||
nativeEffortKey={harnessNativeEffortKey}
|
||||
onEnvVarsChange={onEnvVarsChange}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -147,22 +147,55 @@ test("runtimeSupportsLlmProviderSelection is false for codex and claude", () =>
|
||||
assert.equal(runtimeSupportsLlmProviderSelection("claude"), false);
|
||||
});
|
||||
|
||||
test("resetConfigForHarnessChange clears harness-specific values", () => {
|
||||
test("resetConfigForHarnessChange clears harness-specific values but preserves effort keys", () => {
|
||||
// Delta-5 global-scope rule: ALL effort keys are preserved across runtime
|
||||
// switches at global scope. Both BUZZ_AGENT_THINKING_EFFORT and
|
||||
// GOOSE_THINKING_EFFORT survive a switch — each runtime reads only its own key.
|
||||
const config = {
|
||||
env_vars: { BUZZ_AGENT_THINKING_EFFORT: "high", KEEP_ME: "yes" },
|
||||
env_vars: {
|
||||
BUZZ_AGENT_THINKING_EFFORT: "high",
|
||||
GOOSE_THINKING_EFFORT: "medium",
|
||||
KEEP_ME: "yes",
|
||||
},
|
||||
model: "claude-opus",
|
||||
preferred_runtime: "buzz-agent",
|
||||
provider: "anthropic",
|
||||
};
|
||||
|
||||
assert.deepEqual(resetConfigForHarnessChange(config, "claude"), {
|
||||
env_vars: { KEEP_ME: "yes" },
|
||||
env_vars: {
|
||||
BUZZ_AGENT_THINKING_EFFORT: "high",
|
||||
GOOSE_THINKING_EFFORT: "medium",
|
||||
KEEP_ME: "yes",
|
||||
},
|
||||
model: null,
|
||||
preferred_runtime: "claude",
|
||||
provider: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("resetConfigForHarnessChange buzz-to-goose preserves both runtimes native effort keys", () => {
|
||||
// Global switch: BUZZ_AGENT_THINKING_EFFORT and GOOSE_THINKING_EFFORT both
|
||||
// survive. Each runtime descriptor uses only its own key; the other is a
|
||||
// foreign key that its descriptor strips (Phase 3 invariant). At the global
|
||||
// mutation layer, no key is deleted.
|
||||
const config = {
|
||||
env_vars: {
|
||||
BUZZ_AGENT_THINKING_EFFORT: "high",
|
||||
GOOSE_THINKING_EFFORT: "low",
|
||||
},
|
||||
model: "gpt-4",
|
||||
preferred_runtime: "buzz-agent",
|
||||
provider: "openai",
|
||||
};
|
||||
|
||||
const result = resetConfigForHarnessChange(config, "goose");
|
||||
assert.deepEqual(result.env_vars, {
|
||||
BUZZ_AGENT_THINKING_EFFORT: "high",
|
||||
GOOSE_THINKING_EFFORT: "low",
|
||||
});
|
||||
});
|
||||
|
||||
test("resetConfigForHarnessChange preserves compatible provider selection", () => {
|
||||
const config = {
|
||||
env_vars: { KEEP_ME: "yes" },
|
||||
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
AcpRuntimeCatalogEntry,
|
||||
GlobalAgentConfig,
|
||||
} from "@/shared/api/types";
|
||||
import { BUZZ_AGENT_THINKING_EFFORT } from "./buzzAgentConfig";
|
||||
import type { RuntimeFileConfigSubset } from "@/shared/api/tauri";
|
||||
// Dialogs import getDefaultPersonaRuntime via this re-export; lib code imports
|
||||
// directly from lib/resolvePersonaRuntime.
|
||||
@@ -204,13 +203,21 @@ export function runtimeSupportsLlmProviderSelection(runtimeId: string) {
|
||||
return runtimeId === "buzz-agent" || runtimeId === "goose";
|
||||
}
|
||||
|
||||
/** Clears values whose meaning or support changes with the selected harness. */
|
||||
/** Clears values whose meaning or support changes with the selected harness.
|
||||
*
|
||||
* Used at **global and onboarding scope** — where the Delta-5 global-scope
|
||||
* mutation rule applies: native effort keys from ALL runtimes are preserved
|
||||
* (both BUZZ_AGENT_THINKING_EFFORT and GOOSE_THINKING_EFFORT survive a switch).
|
||||
* Legacy-key deletion and native-key clearing happen only at record/persona
|
||||
* tiers via the effort transition mutation in `selectionOnRuntimeChange`.
|
||||
*/
|
||||
export function resetConfigForHarnessChange(
|
||||
config: GlobalAgentConfig,
|
||||
runtimeId: string,
|
||||
): GlobalAgentConfig {
|
||||
// Do NOT delete any effort keys: global scope preserves both runtimes'
|
||||
// native keys and the legacy key (Delta-5 global-scope mutation rule).
|
||||
const nextEnvVars = { ...config.env_vars };
|
||||
delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT];
|
||||
|
||||
return {
|
||||
...config,
|
||||
|
||||
@@ -4,6 +4,29 @@
|
||||
* them without pulling in React, Tauri IPC, or TanStack Query.
|
||||
*/
|
||||
|
||||
import {
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
normalizeEffortValue,
|
||||
} from "./buzzAgentConfig";
|
||||
|
||||
const BAKED_STRUCTURED_KEYS_BASE = new Set([
|
||||
"BUZZ_AGENT_PROVIDER",
|
||||
"BUZZ_AGENT_MODEL",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns the set of baked env keys hidden by structured controls.
|
||||
* Only the current runtime's native effort key is hidden; a baked legacy key
|
||||
* for a non-buzz-agent runtime remains visible as an advanced row (Delta-4).
|
||||
*/
|
||||
export function bakedStructuredKeys(
|
||||
nativeEffortKey: string | undefined,
|
||||
): Set<string> {
|
||||
const keys = new Set(BAKED_STRUCTURED_KEYS_BASE);
|
||||
if (nativeEffortKey) keys.add(nativeEffortKey);
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the provider option label for the zero-value (inherit) option when a
|
||||
* baked provider is present. Falls back to the raw provider id when the id
|
||||
@@ -143,11 +166,50 @@ export function getInheritedAgentDefaults(
|
||||
provider: string | null;
|
||||
},
|
||||
bakedEnv: readonly BakedEnvEntry[] | undefined,
|
||||
options?: {
|
||||
/**
|
||||
* The native effort env key for the current runtime (e.g. `GOOSE_THINKING_EFFORT`).
|
||||
* When provided (and different from the legacy key), the native key is checked
|
||||
* first and baked lookup uses native-only (no legacy fallback for baked tier).
|
||||
* Defaults to `BUZZ_AGENT_THINKING_EFFORT` (buzz-agent behavior unchanged).
|
||||
*/
|
||||
nativeEffortKey?: string;
|
||||
/**
|
||||
* Canonical effort values for the current runtime (from `runtime.acceptedEffortValues`).
|
||||
* Used to normalize the global native effort value before display.
|
||||
* Pass `null` for buzz-agent (per-model catalog, no static vocabulary).
|
||||
*/
|
||||
acceptedEffortValues?: readonly string[] | null;
|
||||
},
|
||||
): {
|
||||
effort: InheritedDefault;
|
||||
model: InheritedDefault;
|
||||
provider: InheritedDefault;
|
||||
} {
|
||||
const nativeEffortKey =
|
||||
options?.nativeEffortKey ?? BUZZ_AGENT_THINKING_EFFORT;
|
||||
|
||||
// Effort value from global config: native key only.
|
||||
// Global legacy (BUZZ_AGENT_THINKING_EFFORT for non-buzz-agent runtimes) is
|
||||
// excluded — mirrors effort_tier_alias(global_tier=true) in Rust: spawn and
|
||||
// the reader both exclude legacy at the global tier. For Goose, the legacy
|
||||
// key is buzz-agent's native key (Delta 5); consuming it here would display
|
||||
// buzz-agent's effort setting as Goose effort. Normalize the raw value so
|
||||
// global GOOSE_THINKING_EFFORT=xhigh displays as canonical "max", not "xhigh"
|
||||
// (acceptedEffortValues drives normalization; null = buzz-agent pass-through).
|
||||
const globalNativeRaw = globalConfig.env_vars[nativeEffortKey]?.trim();
|
||||
const globalNativeEffort = globalNativeRaw
|
||||
? (normalizeEffortValue(
|
||||
globalNativeRaw,
|
||||
options?.acceptedEffortValues ?? null,
|
||||
) ?? null)
|
||||
: null;
|
||||
const globalEffortValue = globalNativeEffort || null;
|
||||
|
||||
// Baked effort lookup: always use the native key (never the legacy key at
|
||||
// baked tier, mirroring the global-tier native-only rule).
|
||||
const bakedEffortKey = nativeEffortKey;
|
||||
|
||||
return {
|
||||
provider: resolveInheritedDefault(
|
||||
globalConfig.provider,
|
||||
@@ -175,10 +237,22 @@ export function getInheritedAgentDefaults(
|
||||
? { source: "build", value: fallback }
|
||||
: { source: null, value: "" };
|
||||
})(),
|
||||
effort: resolveInheritedDefault(
|
||||
globalConfig.env_vars.BUZZ_AGENT_THINKING_EFFORT,
|
||||
bakedEnv,
|
||||
"BUZZ_AGENT_THINKING_EFFORT",
|
||||
),
|
||||
effort: (() => {
|
||||
// Global config effort wins (native then legacy fallback).
|
||||
if (globalEffortValue) {
|
||||
return {
|
||||
source: "global" as InheritedDefaultSource,
|
||||
value: globalEffortValue,
|
||||
};
|
||||
}
|
||||
// Baked effort — native-only lookup for non-buzz-agent runtimes.
|
||||
const baked = bakedEnv?.find(
|
||||
(entry) => entry.key === bakedEffortKey && !entry.masked,
|
||||
);
|
||||
const bakedValue = baked?.value.trim() ?? "";
|
||||
return bakedValue
|
||||
? { source: "build" as InheritedDefaultSource, value: bakedValue }
|
||||
: { source: null, value: "" };
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
BUZZ_AGENT_THINKING_EFFORT_VALUES,
|
||||
getProviderEffortConfig,
|
||||
isBuzzAgentRuntime,
|
||||
normalizeEffortValue,
|
||||
} from "./buzzAgentConfig.ts";
|
||||
import { resolveEffortFromEnv } from "../lib/agentConfigCore.ts";
|
||||
import { applyHarnessNativeEffortChange } from "./buzzAgentModelTuningFields.tsx";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thinking effort values
|
||||
@@ -621,3 +624,345 @@ test("effort none is invalid for anthropic manual-budget (should trigger auto-cl
|
||||
"none must not be in manual-budget set",
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyHarnessNativeEffortChange — production mutation contract
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// These tests exercise the PRODUCTION helper imported from
|
||||
// buzzAgentModelTuningFields.tsx — the same function called by
|
||||
// HarnessNativeEffortFields.handleChange and AgentConfigFields' effort onChange.
|
||||
// Two scope variants:
|
||||
// record/persona scope: legacyKey = BUZZ_AGENT_THINKING_EFFORT (atomically deleted)
|
||||
// global/onboarding scope: legacyKey = null (Delta 5: never delete foreign native key)
|
||||
//
|
||||
// Source: goose `crates/goose-provider-types/src/thinking.rs:277-308`
|
||||
// Canonical: off|low|medium|high|max (exactly 5 values, no minimal/none/xhigh)
|
||||
|
||||
const GOOSE_NATIVE_KEY = "GOOSE_THINKING_EFFORT";
|
||||
// Forward-declare here so mutation + normalization tests share one definition.
|
||||
const GOOSE_VALUES = ["off", "low", "medium", "high", "max"];
|
||||
|
||||
test("harness_native_save_writes_native_key_and_deletes_legacy", () => {
|
||||
// Legacy-only state (pre-migration Goose record): selecting a value must
|
||||
// write the native key and atomically delete the legacy key.
|
||||
const initial = { [BUZZ_AGENT_THINKING_EFFORT]: "high" };
|
||||
const result = applyHarnessNativeEffortChange(
|
||||
initial,
|
||||
GOOSE_NATIVE_KEY,
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
"medium",
|
||||
);
|
||||
assert.equal(result[GOOSE_NATIVE_KEY], "medium", "native key written");
|
||||
assert.equal(
|
||||
Object.hasOwn(result, BUZZ_AGENT_THINKING_EFFORT),
|
||||
false,
|
||||
"legacy key deleted atomically on save",
|
||||
);
|
||||
});
|
||||
|
||||
test("harness_native_clear_deletes_both_native_and_legacy_keys", () => {
|
||||
// When clear is selected (value=""), both native and legacy keys are removed.
|
||||
const initial = {
|
||||
[GOOSE_NATIVE_KEY]: "high",
|
||||
[BUZZ_AGENT_THINKING_EFFORT]: "xhigh",
|
||||
};
|
||||
const result = applyHarnessNativeEffortChange(
|
||||
initial,
|
||||
GOOSE_NATIVE_KEY,
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(result, GOOSE_NATIVE_KEY),
|
||||
false,
|
||||
"native key deleted on clear",
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(result, BUZZ_AGENT_THINKING_EFFORT),
|
||||
false,
|
||||
"legacy key deleted on clear",
|
||||
);
|
||||
});
|
||||
|
||||
test("harness_native_save_does_not_disturb_unrelated_env_vars", () => {
|
||||
// Saving effort must not affect unrelated env keys.
|
||||
const initial = {
|
||||
[BUZZ_AGENT_THINKING_EFFORT]: "medium",
|
||||
SOME_OTHER_KEY: "value",
|
||||
GOOSE_MAX_TOKENS: "4096",
|
||||
};
|
||||
const result = applyHarnessNativeEffortChange(
|
||||
initial,
|
||||
GOOSE_NATIVE_KEY,
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
"off",
|
||||
);
|
||||
assert.equal(result.SOME_OTHER_KEY, "value", "unrelated key preserved");
|
||||
assert.equal(result.GOOSE_MAX_TOKENS, "4096", "numeric key preserved");
|
||||
assert.equal(result[GOOSE_NATIVE_KEY], "off", "native key written");
|
||||
});
|
||||
|
||||
test("harness_native_clear_does_not_disturb_unrelated_env_vars", () => {
|
||||
// Clearing effort must not affect unrelated env keys.
|
||||
const initial = {
|
||||
[GOOSE_NATIVE_KEY]: "high",
|
||||
SOME_OTHER_KEY: "value",
|
||||
GOOSE_MAX_TOKENS: "4096",
|
||||
};
|
||||
const result = applyHarnessNativeEffortChange(
|
||||
initial,
|
||||
GOOSE_NATIVE_KEY,
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
result.SOME_OTHER_KEY,
|
||||
"value",
|
||||
"unrelated key preserved after clear",
|
||||
);
|
||||
assert.equal(
|
||||
result.GOOSE_MAX_TOKENS,
|
||||
"4096",
|
||||
"numeric key preserved after clear",
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(result, GOOSE_NATIVE_KEY),
|
||||
false,
|
||||
"native key deleted",
|
||||
);
|
||||
});
|
||||
|
||||
test("harness_native_global_scope_save_preserves_buzz_agent_effort", () => {
|
||||
// At global/onboarding scope (legacyKey=null), saving Goose effort must NOT
|
||||
// delete BUZZ_AGENT_THINKING_EFFORT — it is buzz-agent's own global native key
|
||||
// (Delta 5: Goose and buzz-agent global keys coexist independently).
|
||||
const initial = {
|
||||
[BUZZ_AGENT_THINKING_EFFORT]: "high", // buzz-agent's independent global effort
|
||||
SOME_OTHER_KEY: "value",
|
||||
};
|
||||
const result = applyHarnessNativeEffortChange(
|
||||
initial,
|
||||
GOOSE_NATIVE_KEY,
|
||||
null, // global scope: never delete foreign native key
|
||||
"medium",
|
||||
);
|
||||
assert.equal(result[GOOSE_NATIVE_KEY], "medium", "Goose native key written");
|
||||
assert.equal(
|
||||
result[BUZZ_AGENT_THINKING_EFFORT],
|
||||
"high",
|
||||
"buzz-agent effort preserved (Delta 5: no foreign-key deletion)",
|
||||
);
|
||||
assert.equal(result.SOME_OTHER_KEY, "value", "unrelated key preserved");
|
||||
});
|
||||
|
||||
test("harness_native_global_scope_clear_preserves_buzz_agent_effort", () => {
|
||||
// At global/onboarding scope, clearing Goose effort (Inherit) must NOT
|
||||
// delete BUZZ_AGENT_THINKING_EFFORT (Delta 5).
|
||||
const initial = {
|
||||
[GOOSE_NATIVE_KEY]: "medium",
|
||||
[BUZZ_AGENT_THINKING_EFFORT]: "high",
|
||||
};
|
||||
const result = applyHarnessNativeEffortChange(
|
||||
initial,
|
||||
GOOSE_NATIVE_KEY,
|
||||
null, // global scope
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(result, GOOSE_NATIVE_KEY),
|
||||
false,
|
||||
"Goose native key cleared",
|
||||
);
|
||||
assert.equal(
|
||||
result[BUZZ_AGENT_THINKING_EFFORT],
|
||||
"high",
|
||||
"buzz-agent effort preserved on Goose clear",
|
||||
);
|
||||
});
|
||||
|
||||
test("harness_native_five_canonical_choices_for_goose", () => {
|
||||
// Goose's accepted effort vocabulary is exactly the 5 canonical values.
|
||||
// The component offers these as the selectable options (plus "Inherit").
|
||||
const GOOSE_ACCEPTED = ["off", "low", "medium", "high", "max"];
|
||||
assert.equal(
|
||||
GOOSE_ACCEPTED.length,
|
||||
5,
|
||||
"exactly 5 canonical Goose effort values",
|
||||
);
|
||||
assert.deepEqual(
|
||||
GOOSE_ACCEPTED,
|
||||
["off", "low", "medium", "high", "max"],
|
||||
"canonical order: off→low→medium→high→max",
|
||||
);
|
||||
// Verify minimal is NOT a Goose value (buzz-agent only).
|
||||
assert.equal(
|
||||
GOOSE_ACCEPTED.includes("minimal"),
|
||||
false,
|
||||
"minimal is not a Goose value",
|
||||
);
|
||||
// Verify none is NOT canonical (it is an alias for off).
|
||||
assert.equal(
|
||||
GOOSE_ACCEPTED.includes("none"),
|
||||
false,
|
||||
"none is not canonical (alias for off)",
|
||||
);
|
||||
});
|
||||
|
||||
test("harness_native_legacy_to_native_migration_at_persona_scope_renders_value", () => {
|
||||
// A legacy-only Goose persona (scope="definition") with a valid legacy value
|
||||
// must display the normalized canonical value via the legacy fallback path.
|
||||
// Exercises resolveEffortFromEnv — the same function HarnessNativeEffortFields
|
||||
// calls — so this test pins the actual production read path.
|
||||
|
||||
// Case 1: buzz-agent alias xhigh normalizes to max.
|
||||
const result1 = resolveEffortFromEnv(
|
||||
{ [BUZZ_AGENT_THINKING_EFFORT]: "xhigh" },
|
||||
"GOOSE_THINKING_EFFORT",
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
GOOSE_VALUES,
|
||||
);
|
||||
assert.equal(
|
||||
result1.value,
|
||||
"max",
|
||||
"xhigh alias normalized to max for display",
|
||||
);
|
||||
assert.equal(
|
||||
result1.legacyConsumed,
|
||||
true,
|
||||
"legacy key consumed when native absent",
|
||||
);
|
||||
|
||||
// Case 2: canonical value passes through unchanged.
|
||||
const result2 = resolveEffortFromEnv(
|
||||
{ [BUZZ_AGENT_THINKING_EFFORT]: "high" },
|
||||
"GOOSE_THINKING_EFFORT",
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
GOOSE_VALUES,
|
||||
);
|
||||
assert.equal(result2.value, "high", "canonical legacy value passes through");
|
||||
assert.equal(
|
||||
result2.legacyConsumed,
|
||||
true,
|
||||
"legacy key consumed when native absent",
|
||||
);
|
||||
});
|
||||
|
||||
test("harness_native_invalid_native_plus_valid_legacy_renders_legacy_value", () => {
|
||||
// Invalid native value (e.g. "minimal" — not in Goose vocab) + valid legacy value:
|
||||
// resolveEffortFromEnv must skip the invalid native and fall back to the valid legacy.
|
||||
// This verifies that the component and deriveAgentConfigFieldModel agree on the read.
|
||||
const result = resolveEffortFromEnv(
|
||||
{
|
||||
GOOSE_THINKING_EFFORT: "minimal", // invalid for Goose
|
||||
[BUZZ_AGENT_THINKING_EFFORT]: "high", // valid legacy
|
||||
},
|
||||
"GOOSE_THINKING_EFFORT",
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
GOOSE_VALUES,
|
||||
);
|
||||
assert.equal(
|
||||
result.value,
|
||||
"high",
|
||||
"invalid native skipped; valid legacy provides the displayed value",
|
||||
);
|
||||
assert.equal(
|
||||
result.legacyConsumed,
|
||||
true,
|
||||
"legacyConsumed is true when native invalid and legacy valid",
|
||||
);
|
||||
});
|
||||
|
||||
test("harness_native_invalid_legacy_renders_empty_and_row_stays_visible", () => {
|
||||
// Invalid legacy value only (e.g. "minimal" — not in Goose vocab):
|
||||
// resolveEffortFromEnv returns null (control shows Inherit/empty) and
|
||||
// legacyConsumed is false (row stays visible as an advanced env row).
|
||||
const result = resolveEffortFromEnv(
|
||||
{ [BUZZ_AGENT_THINKING_EFFORT]: "minimal" },
|
||||
"GOOSE_THINKING_EFFORT",
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
GOOSE_VALUES,
|
||||
);
|
||||
assert.equal(
|
||||
result.value,
|
||||
null,
|
||||
"invalid legacy returns null — control shows Inherit",
|
||||
);
|
||||
assert.equal(
|
||||
result.legacyConsumed,
|
||||
false,
|
||||
"legacyConsumed is false — row stays visible as advanced env row",
|
||||
);
|
||||
});
|
||||
|
||||
test("harness_native_global_scope_no_legacy_fallback", () => {
|
||||
// At global scope the caller passes null for legacyKey — enforcing the tier boundary.
|
||||
// Even if a legacy key exists, resolveEffortFromEnv must not consume it.
|
||||
const result = resolveEffortFromEnv(
|
||||
{
|
||||
GOOSE_THINKING_EFFORT: "",
|
||||
[BUZZ_AGENT_THINKING_EFFORT]: "high",
|
||||
},
|
||||
"GOOSE_THINKING_EFFORT",
|
||||
null, // global scope: no legacy fallback
|
||||
GOOSE_VALUES,
|
||||
);
|
||||
assert.equal(
|
||||
result.value,
|
||||
null,
|
||||
"global scope: legacy key ignored when legacyKey=null",
|
||||
);
|
||||
assert.equal(
|
||||
result.legacyConsumed,
|
||||
false,
|
||||
"legacyConsumed is false at global scope",
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeEffortValue — Goose canonical alias normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Source: goose `crates/goose-provider-types/src/thinking.rs:277-308`
|
||||
// Canonical: off|low|medium|high|max
|
||||
// Aliases: none|disabled→off, med→medium, xhigh→max (case-insensitive)
|
||||
// (GOOSE_VALUES is defined above in the HarnessNativeEffortFields section.)
|
||||
|
||||
test("normalizeEffortValue_canonical_values_pass_through", () => {
|
||||
for (const v of GOOSE_VALUES) {
|
||||
assert.equal(normalizeEffortValue(v, GOOSE_VALUES), v);
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizeEffortValue_none_to_off", () => {
|
||||
assert.equal(normalizeEffortValue("none", GOOSE_VALUES), "off");
|
||||
});
|
||||
|
||||
test("normalizeEffortValue_disabled_to_off", () => {
|
||||
assert.equal(normalizeEffortValue("disabled", GOOSE_VALUES), "off");
|
||||
});
|
||||
|
||||
test("normalizeEffortValue_med_to_medium", () => {
|
||||
assert.equal(normalizeEffortValue("med", GOOSE_VALUES), "medium");
|
||||
});
|
||||
|
||||
test("normalizeEffortValue_xhigh_to_max", () => {
|
||||
assert.equal(normalizeEffortValue("xhigh", GOOSE_VALUES), "max");
|
||||
});
|
||||
|
||||
test("normalizeEffortValue_case_insensitive", () => {
|
||||
assert.equal(normalizeEffortValue("HIGH", GOOSE_VALUES), "high");
|
||||
assert.equal(normalizeEffortValue("NONE", GOOSE_VALUES), "off");
|
||||
assert.equal(normalizeEffortValue("XHIGH", GOOSE_VALUES), "max");
|
||||
});
|
||||
|
||||
test("normalizeEffortValue_invalid_returns_null", () => {
|
||||
assert.equal(normalizeEffortValue("minimal", GOOSE_VALUES), null);
|
||||
assert.equal(normalizeEffortValue("unknown", GOOSE_VALUES), null);
|
||||
});
|
||||
|
||||
test("normalizeEffortValue_null_acceptedValues_passthrough", () => {
|
||||
// buzz-agent path: no static vocab, pass through raw value unchanged.
|
||||
assert.equal(normalizeEffortValue("xhigh", null), "xhigh");
|
||||
assert.equal(normalizeEffortValue("minimal", null), "minimal");
|
||||
});
|
||||
|
||||
@@ -8,6 +8,48 @@
|
||||
/** Env var key for the thinking/effort level sent to the LLM. */
|
||||
export const BUZZ_AGENT_THINKING_EFFORT = "BUZZ_AGENT_THINKING_EFFORT";
|
||||
|
||||
/**
|
||||
* All native thinking-effort env keys across all known runtimes.
|
||||
* Used to strip foreign effort keys from a runtime's effective descriptor and
|
||||
* to clear all effort-related keys when switching runtimes (record/persona scope).
|
||||
* Must stay in sync with `ALL_KNOWN_EFFORT_KEYS` in `config_bridge/mod.rs`.
|
||||
*/
|
||||
export const ALL_KNOWN_EFFORT_KEYS: readonly string[] = [
|
||||
BUZZ_AGENT_THINKING_EFFORT, // buzz-agent native (= legacy key)
|
||||
"GOOSE_THINKING_EFFORT", // Goose native
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Normalize a raw effort value to its canonical form for a runtime with a
|
||||
* static effort vocabulary (e.g. Goose). Returns `null` when the value is
|
||||
* invalid for the given canonical set.
|
||||
*
|
||||
* Aliases (case-insensitive, from Goose `thinking.rs:277-297`):
|
||||
* none|disabled → off, med → medium, xhigh → max
|
||||
* Also case-folds (e.g. "HIGH" → "high").
|
||||
*
|
||||
* Pass `null` for `acceptedValues` to skip normalization (buzz-agent path).
|
||||
*/
|
||||
export function normalizeEffortValue(
|
||||
raw: string,
|
||||
acceptedValues: readonly string[] | null,
|
||||
): string | null {
|
||||
if (!acceptedValues) return raw; // buzz-agent: pass through, no static vocab
|
||||
const lower = raw.toLowerCase();
|
||||
// Canonical direct match after case-fold.
|
||||
if (acceptedValues.includes(lower)) return lower;
|
||||
// Known aliases (mirrors GOOSE_EFFORT_NORMALIZATION in runtime_metadata.rs).
|
||||
const ALIASES: Record<string, string> = {
|
||||
none: "off",
|
||||
disabled: "off",
|
||||
med: "medium",
|
||||
xhigh: "max",
|
||||
};
|
||||
const mapped = ALIASES[lower];
|
||||
if (mapped && acceptedValues.includes(mapped)) return mapped;
|
||||
return null; // invalid for this harness
|
||||
}
|
||||
|
||||
/** Env var key for the maximum output token count per turn. */
|
||||
export const BUZZ_AGENT_MAX_OUTPUT_TOKENS = "BUZZ_AGENT_MAX_OUTPUT_TOKENS";
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ import { Input } from "@/shared/ui/input";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import type { EnvVarsValue } from "./EnvVarsEditor";
|
||||
import type { NumericDescriptor } from "../lib/agentConfigCore";
|
||||
import { numericTuningPlaceholder } from "../lib/agentConfigCore";
|
||||
import {
|
||||
numericTuningPlaceholder,
|
||||
resolveEffortFromEnv,
|
||||
} from "../lib/agentConfigCore";
|
||||
import {
|
||||
AgentDropdownSelect,
|
||||
type AgentDropdownOption,
|
||||
@@ -19,6 +22,7 @@ import {
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
BUZZ_AGENT_THINKING_EFFORT_VALUES,
|
||||
getProviderEffortConfig,
|
||||
normalizeEffortValue,
|
||||
} from "./buzzAgentConfig";
|
||||
|
||||
/**
|
||||
@@ -35,6 +39,7 @@ import {
|
||||
* env-vars map pattern and the `setConfig` pattern).
|
||||
*/
|
||||
export function EffortSelectField({
|
||||
canonicalValues,
|
||||
currentEffort,
|
||||
disabled = false,
|
||||
emptyOptionLabel,
|
||||
@@ -53,6 +58,14 @@ export function EffortSelectField({
|
||||
testId,
|
||||
useCustomSelect = false,
|
||||
}: {
|
||||
/**
|
||||
* When set, renders these values as the full option list (all valid, no
|
||||
* greying, no default marking). Used by harness-native runtimes whose effort
|
||||
* vocabulary is fixed and independent of the provider/model catalog.
|
||||
* When set, `effortValid`, `effortDefault`, and `showUnavailableOptions` are
|
||||
* ignored.
|
||||
*/
|
||||
canonicalValues?: readonly string[];
|
||||
/** Current effort value from env vars ("" = inherit). */
|
||||
currentEffort: string;
|
||||
/** Disable the dropdown. */
|
||||
@@ -105,21 +118,26 @@ export function EffortSelectField({
|
||||
: effortDefault === null
|
||||
? "Inherit (default)"
|
||||
: (inheritFallbackLabel ?? "Inherit");
|
||||
const effortOptions: AgentDropdownOption[] = [
|
||||
{ label: emptyOptionLabel ?? inheritLabel, value: "" },
|
||||
...BUZZ_AGENT_THINKING_EFFORT_VALUES.flatMap((v) => {
|
||||
const isValid = (effortValid as readonly string[]).includes(v);
|
||||
if (!showUnavailableOptions && !isValid) return [];
|
||||
const isDefault = v === effortDefault;
|
||||
return [
|
||||
{
|
||||
disabled: !isValid,
|
||||
label: isDefault ? `${v} (default)` : v,
|
||||
value: v,
|
||||
},
|
||||
const effortOptions: AgentDropdownOption[] = canonicalValues
|
||||
? [
|
||||
{ label: emptyOptionLabel ?? inheritLabel, value: "" },
|
||||
...canonicalValues.map((v) => ({ label: v, value: v })),
|
||||
]
|
||||
: [
|
||||
{ label: emptyOptionLabel ?? inheritLabel, value: "" },
|
||||
...BUZZ_AGENT_THINKING_EFFORT_VALUES.flatMap((v) => {
|
||||
const isValid = (effortValid as readonly string[]).includes(v);
|
||||
if (!showUnavailableOptions && !isValid) return [];
|
||||
const isDefault = v === effortDefault;
|
||||
return [
|
||||
{
|
||||
disabled: !isValid,
|
||||
label: isDefault ? `${v} (default)` : v,
|
||||
value: v,
|
||||
},
|
||||
];
|
||||
}),
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1.5", fieldClassName)}>
|
||||
@@ -352,3 +370,161 @@ export function BuzzAgentModelTuningFields({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure mutation helper for harness-native effort controls.
|
||||
*
|
||||
* Write semantics (scope-aware):
|
||||
* - Save (value non-empty): write `nativeKey`; delete `legacyKey` when supplied.
|
||||
* - Clear (value = ""): delete `nativeKey`; delete `legacyKey` when supplied.
|
||||
*
|
||||
* `legacyKey` must be supplied at record/persona scope (definition/instance) to
|
||||
* atomically delete `BUZZ_AGENT_THINKING_EFFORT` and prevent stale alias
|
||||
* resurrection. At global/onboarding scope pass `null` — `BUZZ_AGENT_THINKING_EFFORT`
|
||||
* is buzz-agent's own native key there (Delta 5: never delete a foreign runtime's key).
|
||||
*
|
||||
* This function is the single spec for the mutation contract. Both
|
||||
* `HarnessNativeEffortFields.handleChange` and `AgentConfigFields`' effort
|
||||
* `onChange` call it; tests import and exercise it directly.
|
||||
*/
|
||||
export function applyHarnessNativeEffortChange(
|
||||
envVars: EnvVarsValue,
|
||||
nativeKey: string,
|
||||
/** Pass `BUZZ_AGENT_THINKING_EFFORT` at record/persona scope; `null` at global/onboarding. */
|
||||
legacyKey: string | null,
|
||||
value: string,
|
||||
): EnvVarsValue {
|
||||
const next = { ...envVars };
|
||||
if (value === "") {
|
||||
delete next[nativeKey];
|
||||
} else {
|
||||
next[nativeKey] = value;
|
||||
}
|
||||
// Delete legacy key at record/persona scope to prevent stale alias resurrection.
|
||||
// Omitted at global scope — legacy key is buzz-agent's own native key there.
|
||||
if (legacyKey) delete next[legacyKey];
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effort-tuning knob for runtimes with a static canonical effort vocabulary
|
||||
* (e.g. Goose: `off|low|medium|high|max`, driven by `runtime.acceptedEffortValues`).
|
||||
*
|
||||
* Semantics differ from `BuzzAgentModelTuningFields` in two ways:
|
||||
* 1. The option list comes from `acceptedEffortValues` rather than the
|
||||
* provider/model catalog — all listed values are valid, no greying out.
|
||||
* 2. Saves write to the runtime's native key (`nativeEffortKey`) and
|
||||
* **atomically deletes** the legacy key (`BUZZ_AGENT_THINKING_EFFORT`)
|
||||
* to prevent stale alias resurrection. Clears both on empty selection.
|
||||
*
|
||||
* The caller controls whether this component renders; it is shown only when
|
||||
* the selected runtime has `acceptedEffortValues !== null`.
|
||||
*/
|
||||
export function HarnessNativeEffortFields({
|
||||
acceptedEffortValues,
|
||||
envVars,
|
||||
inheritedEnvVars,
|
||||
legacyEnvKey,
|
||||
nativeEffortKey,
|
||||
onEnvVarsChange,
|
||||
}: {
|
||||
/** Ordered canonical effort values from `runtime.acceptedEffortValues`. */
|
||||
acceptedEffortValues: readonly string[];
|
||||
envVars: EnvVarsValue;
|
||||
/**
|
||||
* Inherited defaults (global config env_vars) used to show the Inherit
|
||||
* option label. Normalized to canonical before display.
|
||||
*/
|
||||
inheritedEnvVars?: EnvVarsValue;
|
||||
/**
|
||||
* Legacy effort key for pre-migration personas (e.g. `BUZZ_AGENT_THINKING_EFFORT`).
|
||||
* When the native key is absent and the legacy key holds a valid canonical value,
|
||||
* that value is displayed (the read path mirrors `resolveEffortFromEnv`).
|
||||
* Pass only at record/persona scope (definition/instance) — omit or pass undefined
|
||||
* at global/onboarding scope to enforce the tier boundary.
|
||||
*/
|
||||
legacyEnvKey?: string;
|
||||
/** The native effort env key for this runtime (e.g. `GOOSE_THINKING_EFFORT`). */
|
||||
nativeEffortKey: string;
|
||||
/**
|
||||
* Replaces the whole envVars record atomically (native write + legacy delete).
|
||||
* Mirrors the pattern used by EditAgentAdvancedFields for env mutations.
|
||||
*/
|
||||
onEnvVarsChange: (next: EnvVarsValue) => void;
|
||||
}) {
|
||||
// Read current effort via the shared policy source: native-first, valid-legacy
|
||||
// fallback (when legacyEnvKey is supplied). This guarantees the component and
|
||||
// deriveAgentConfigFieldModel agree on what value to display at any scope.
|
||||
const { value: currentEffort } = resolveEffortFromEnv(
|
||||
envVars,
|
||||
nativeEffortKey,
|
||||
legacyEnvKey ?? null,
|
||||
acceptedEffortValues,
|
||||
);
|
||||
|
||||
// Inherited effort from global config; normalize so xhigh shows as max, etc.
|
||||
// NOTE: Native-key only — no legacy fallback on the inherited map. The merged
|
||||
// inheritedEnvVars (global + persona env) cannot distinguish persona-sourced
|
||||
// legacy (should display) from global-sourced legacy (must NOT per Delta 4).
|
||||
// A naive legacy fallback here would over-report global legacy and violate the
|
||||
// tier boundary. Native-only is intentional; persona-sourced legacy effort will
|
||||
// be visible via the current-value read once the user opens a persona with
|
||||
// legacy data (the control shows the legacy value via resolveEffortFromEnv above).
|
||||
const rawInherited = inheritedEnvVars?.[nativeEffortKey] ?? "";
|
||||
const inheritedEffort = rawInherited
|
||||
? (normalizeEffortValue(rawInherited, acceptedEffortValues) ?? undefined)
|
||||
: undefined;
|
||||
|
||||
const inheritLabel = inheritedEffort
|
||||
? `Inherit (${inheritedEffort})`
|
||||
: "Inherit";
|
||||
|
||||
const effortOptions: AgentDropdownOption[] = [
|
||||
{ label: inheritLabel, value: "" },
|
||||
...acceptedEffortValues.map((v) => ({ label: v, value: v })),
|
||||
];
|
||||
|
||||
function handleChange(value: string) {
|
||||
// Record/persona scope: always pass the legacy key so it is atomically deleted.
|
||||
onEnvVarsChange(
|
||||
applyHarnessNativeEffortChange(
|
||||
envVars,
|
||||
nativeEffortKey,
|
||||
legacyEnvKey ?? BUZZ_AGENT_THINKING_EFFORT,
|
||||
value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
harness model tuning
|
||||
</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="harness-thinking-effort"
|
||||
>
|
||||
Thinking / Effort
|
||||
</label>
|
||||
<AgentDropdownSelect
|
||||
id="harness-thinking-effort"
|
||||
onValueChange={handleChange}
|
||||
options={effortOptions}
|
||||
testId="harness-thinking-effort-select"
|
||||
value={currentEffort ?? ""}
|
||||
/>
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
id="help-harness-thinking-effort"
|
||||
>
|
||||
Controls how much reasoning effort the LLM applies per turn. Leave
|
||||
blank to inherit from the global or persona default.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,11 +36,16 @@ import {
|
||||
countNonSecretInheritedEnvVars,
|
||||
getBakedModelInheritLabel,
|
||||
getAdvancedInheritedSummary,
|
||||
bakedStructuredKeys,
|
||||
getGlobalModelFallback,
|
||||
getInheritedAgentDefaults,
|
||||
getBakedProviderInheritLabel,
|
||||
resolveInheritedDefault,
|
||||
} from "./bakedEnvHelpers.ts";
|
||||
import {
|
||||
deriveAgentConfigFieldModel,
|
||||
getRenderableEffortField,
|
||||
} from "../lib/agentConfigCore.ts";
|
||||
|
||||
// ── Core predicate: provider-selection support ─────────────────────────────
|
||||
|
||||
@@ -1442,3 +1447,334 @@ test("inherited defaults expose a provider-specific model fallback to agent dial
|
||||
value: "goose-claude-opus-4-8",
|
||||
});
|
||||
});
|
||||
|
||||
// ── Baked effort: native-only for non-buzz-agent runtimes ─────────────────
|
||||
//
|
||||
// Plan v3 Delta 4: baked legacy key (BUZZ_AGENT_THINKING_EFFORT) must NOT be
|
||||
// shown as Goose inherited effort. Only the native key is honoured at the baked tier.
|
||||
// Paired with the ★ baked-unmasking pin from pass-3.
|
||||
|
||||
test("baked_legacy_effort_excluded_for_goose_runtime", () => {
|
||||
// Baked env has BUZZ_AGENT_THINKING_EFFORT=high but NOT GOOSE_THINKING_EFFORT.
|
||||
// For a Goose runtime, the baked legacy key must not seed inherited effort.
|
||||
const defaults = getInheritedAgentDefaults(
|
||||
{ env_vars: {}, provider: null, model: null },
|
||||
[{ key: "BUZZ_AGENT_THINKING_EFFORT", value: "high", masked: false }],
|
||||
{ nativeEffortKey: "GOOSE_THINKING_EFFORT" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
defaults.effort,
|
||||
{ source: null, value: "" },
|
||||
"baked legacy must not seed Goose inherited effort",
|
||||
);
|
||||
});
|
||||
|
||||
test("baked_native_effort_included_for_goose_runtime", () => {
|
||||
// Baked env has GOOSE_THINKING_EFFORT=medium. For a Goose runtime, the baked
|
||||
// native key must seed inherited effort (★ baked unmasking pin: value usable,
|
||||
// not ••••••).
|
||||
const defaults = getInheritedAgentDefaults(
|
||||
{ env_vars: {}, provider: null, model: null },
|
||||
[{ key: "GOOSE_THINKING_EFFORT", value: "medium", masked: false }],
|
||||
{ nativeEffortKey: "GOOSE_THINKING_EFFORT" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
defaults.effort,
|
||||
{ source: "build", value: "medium" },
|
||||
"baked native must seed Goose inherited effort",
|
||||
);
|
||||
});
|
||||
|
||||
test("baked_masked_native_effort_excluded_from_inherited_defaults", () => {
|
||||
// A masked baked value (•••) must not be treated as a real inherited effort.
|
||||
// (★ baked unmasking pin: masked ••••••must never read as a real inherited value.)
|
||||
const defaults = getInheritedAgentDefaults(
|
||||
{ env_vars: {}, provider: null, model: null },
|
||||
[{ key: "GOOSE_THINKING_EFFORT", value: "••••••", masked: true }],
|
||||
{ nativeEffortKey: "GOOSE_THINKING_EFFORT" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
defaults.effort,
|
||||
{ source: null, value: "" },
|
||||
"masked baked value must not seed inherited effort",
|
||||
);
|
||||
});
|
||||
|
||||
// ── bakedStructuredKeys: runtime-sensitive hidden-key set ─────────────────
|
||||
|
||||
test("bakedStructuredKeys_with_native_effort_key_hides_both_fixed_and_native", () => {
|
||||
const keys = bakedStructuredKeys("GOOSE_THINKING_EFFORT");
|
||||
assert.ok(
|
||||
keys.has("BUZZ_AGENT_PROVIDER"),
|
||||
"BUZZ_AGENT_PROVIDER always hidden",
|
||||
);
|
||||
assert.ok(keys.has("BUZZ_AGENT_MODEL"), "BUZZ_AGENT_MODEL always hidden");
|
||||
assert.ok(keys.has("GOOSE_THINKING_EFFORT"), "native effort key hidden");
|
||||
assert.equal(
|
||||
keys.has("BUZZ_AGENT_THINKING_EFFORT"),
|
||||
false,
|
||||
"legacy key NOT hidden for Goose (global legacy stays as advanced row)",
|
||||
);
|
||||
});
|
||||
|
||||
test("bakedStructuredKeys_without_native_effort_key_hides_only_fixed", () => {
|
||||
const keys = bakedStructuredKeys(undefined);
|
||||
assert.ok(keys.has("BUZZ_AGENT_PROVIDER"));
|
||||
assert.ok(keys.has("BUZZ_AGENT_MODEL"));
|
||||
assert.equal(keys.has("GOOSE_THINKING_EFFORT"), false);
|
||||
assert.equal(keys.has("BUZZ_AGENT_THINKING_EFFORT"), false);
|
||||
});
|
||||
|
||||
// ── getInheritedAgentDefaults: global-tier legacy exclusion + normalization ─
|
||||
//
|
||||
// Plan v3 Delta 4: global legacy is never consumed as structured effort.
|
||||
// Plan v3 Delta 1: global native value normalized before display.
|
||||
|
||||
test("global_legacy_effort_ignored_for_goose_in_inherited_defaults", () => {
|
||||
// Global config has only the legacy key (BUZZ_AGENT_THINKING_EFFORT=high).
|
||||
// For a Goose runtime, that key is buzz-agent's native key (Delta 5) and must
|
||||
// NOT seed the inherited effort display — mirrors effort_tier_alias(global_tier=true).
|
||||
const defaults = getInheritedAgentDefaults(
|
||||
{
|
||||
env_vars: { BUZZ_AGENT_THINKING_EFFORT: "high" },
|
||||
provider: null,
|
||||
model: null,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
nativeEffortKey: "GOOSE_THINKING_EFFORT",
|
||||
acceptedEffortValues: ["off", "low", "medium", "high", "max"],
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
defaults.effort,
|
||||
{ source: null, value: "" },
|
||||
"global legacy key must not seed Goose inherited effort",
|
||||
);
|
||||
});
|
||||
|
||||
test("global_native_xhigh_normalizes_to_max_for_goose_in_inherited_defaults", () => {
|
||||
// Global config has GOOSE_THINKING_EFFORT=xhigh (an accepted Goose alias).
|
||||
// Must be normalized to canonical "max" before display, consistent with the
|
||||
// Rust display path where xhigh → max via acceptedEffortValues (Delta 1).
|
||||
const defaults = getInheritedAgentDefaults(
|
||||
{
|
||||
env_vars: { GOOSE_THINKING_EFFORT: "xhigh" },
|
||||
provider: null,
|
||||
model: null,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
nativeEffortKey: "GOOSE_THINKING_EFFORT",
|
||||
acceptedEffortValues: ["off", "low", "medium", "high", "max"],
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
defaults.effort,
|
||||
{ source: "global", value: "max" },
|
||||
"global native xhigh must normalize to canonical max",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Global Goose effort: descriptor-driven canonical choices ────────────────
|
||||
//
|
||||
// Plan v3 IMPORTANT 1: AgentConfigFields at global/onboarding scope for a
|
||||
// harness-native runtime must use descriptor metadata for effort options and
|
||||
// display, not the Buzz provider/model catalog (Delta 5 coexistence).
|
||||
|
||||
const GOOSE_GLOBAL_RUNTIME = {
|
||||
id: "goose",
|
||||
thinkingEnvVar: "GOOSE_THINKING_EFFORT",
|
||||
acceptedEffortValues: ["off", "low", "medium", "high", "max"],
|
||||
providerEnvVar: "GOOSE_PROVIDER",
|
||||
modelEnvVar: "GOOSE_MODEL",
|
||||
maxTokensEnvVar: null,
|
||||
contextLimitEnvVar: null,
|
||||
maxRoundsEnvVar: null,
|
||||
command: "goose",
|
||||
defaultArgs: [],
|
||||
};
|
||||
|
||||
test("global_goose_effort_field_optionSource_is_harnessNative", () => {
|
||||
// At global scope with a Goose runtime, the effort field descriptor must report
|
||||
// optionSource=harnessNative — the signal AgentConfigFields uses to branch to the
|
||||
// metadata-driven canonical control instead of the Buzz provider/model catalog.
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config: { env_vars: {}, provider: null, model: null },
|
||||
runtime: GOOSE_GLOBAL_RUNTIME,
|
||||
scope: "global",
|
||||
});
|
||||
const effortField = getRenderableEffortField(model);
|
||||
assert.ok(
|
||||
effortField,
|
||||
"effort field must be present for Goose at global scope",
|
||||
);
|
||||
assert.equal(
|
||||
effortField.optionSource,
|
||||
"harnessNative",
|
||||
"Goose global effort must be harnessNative (not legacyProviderModelCatalog or buzzAgentCatalog)",
|
||||
);
|
||||
});
|
||||
|
||||
test("global_goose_effort_field_value_xhigh_normalized_to_max", () => {
|
||||
// When global config has GOOSE_THINKING_EFFORT=xhigh, the descriptor value must
|
||||
// be normalized to canonical "max" — the global control shows effortField.value.
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config: {
|
||||
env_vars: { GOOSE_THINKING_EFFORT: "xhigh" },
|
||||
provider: null,
|
||||
model: null,
|
||||
},
|
||||
runtime: GOOSE_GLOBAL_RUNTIME,
|
||||
scope: "global",
|
||||
});
|
||||
const effortField = getRenderableEffortField(model);
|
||||
assert.ok(effortField, "effort field must be present");
|
||||
assert.equal(
|
||||
effortField.value,
|
||||
"max",
|
||||
"xhigh must normalize to canonical max via descriptor",
|
||||
);
|
||||
});
|
||||
|
||||
test("global_goose_effort_field_invalid_value_resolves_to_null", () => {
|
||||
// A global GOOSE_THINKING_EFFORT=minimal (invalid for Goose) must resolve to
|
||||
// null via the descriptor — the control shows empty/Inherit, not the invalid value.
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config: {
|
||||
env_vars: { GOOSE_THINKING_EFFORT: "minimal" },
|
||||
provider: null,
|
||||
model: null,
|
||||
},
|
||||
runtime: GOOSE_GLOBAL_RUNTIME,
|
||||
scope: "global",
|
||||
});
|
||||
const effortField = getRenderableEffortField(model);
|
||||
assert.ok(effortField, "effort field must be present");
|
||||
assert.equal(
|
||||
effortField.value,
|
||||
null,
|
||||
"invalid value must resolve to null (control shows Inherit)",
|
||||
);
|
||||
});
|
||||
|
||||
test("global_goose_effort_field_no_legacy_fallback_at_global_scope", () => {
|
||||
// At global scope, only the native GOOSE_THINKING_EFFORT is considered — not
|
||||
// BUZZ_AGENT_THINKING_EFFORT (Delta 5: that is buzz-agent's own native key).
|
||||
// Even if a global legacy key holds a valid value, the descriptor must report null.
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config: {
|
||||
env_vars: {
|
||||
BUZZ_AGENT_THINKING_EFFORT: "high", // buzz-agent's own global native key
|
||||
},
|
||||
provider: null,
|
||||
model: null,
|
||||
},
|
||||
runtime: GOOSE_GLOBAL_RUNTIME,
|
||||
scope: "global",
|
||||
});
|
||||
const effortField = getRenderableEffortField(model);
|
||||
assert.ok(effortField, "effort field must be present");
|
||||
assert.equal(
|
||||
effortField.value,
|
||||
null,
|
||||
"global legacy BUZZ_AGENT_THINKING_EFFORT must not be consumed as Goose effort",
|
||||
);
|
||||
assert.equal(
|
||||
effortField.legacyConsumedKey,
|
||||
undefined,
|
||||
"no legacy key must be consumed at global scope",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Model-coupled mutation guard: harness-native effort survives heal/auto-clear
|
||||
//
|
||||
// Pins the isHarnessNativeEffort flag that gates heal-on-mount, orphan-clear, and
|
||||
// useEffortAutoClear. All three paths skip the native effort key when this is true.
|
||||
// Descriptor optionSource=harnessNative is the derivation; acceptedEffortValues≠null
|
||||
// is the runtime signal. Tests verify the flag is set for Goose (any value, including
|
||||
// aliases and invalid values) and NOT set for buzz-agent (provider/model-gated path).
|
||||
|
||||
test("harness_native_xhigh_flag_set_so_model_heal_skips_key", () => {
|
||||
// Global GOOSE_THINKING_EFFORT=xhigh: descriptor has optionSource=harnessNative and
|
||||
// value=max. AgentConfigFields derives isHarnessNativeEffort from optionSource, which
|
||||
// gates the heal-on-mount, orphan-clear, and useEffortAutoClear mutation paths.
|
||||
// When true, all three skip the native key — xhigh survives and displays as max.
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config: {
|
||||
env_vars: { GOOSE_THINKING_EFFORT: "xhigh" },
|
||||
provider: null,
|
||||
model: null,
|
||||
},
|
||||
runtime: GOOSE_GLOBAL_RUNTIME,
|
||||
scope: "global",
|
||||
});
|
||||
const effortField = getRenderableEffortField(model);
|
||||
assert.ok(effortField, "effort field present");
|
||||
assert.equal(
|
||||
effortField.optionSource,
|
||||
"harnessNative",
|
||||
"xhigh: optionSource=harnessNative gates model-heal skip (isHarnessNativeEffort=true)",
|
||||
);
|
||||
assert.equal(effortField.value, "max", "xhigh normalizes to max for display");
|
||||
});
|
||||
|
||||
test("harness_native_invalid_minimal_flag_set_so_model_heal_skips_key", () => {
|
||||
// Global GOOSE_THINKING_EFFORT=minimal (invalid for Goose): flag still set,
|
||||
// heal/auto-clear skip the key. Reader shows Inherit (null), spawn skips it.
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config: {
|
||||
env_vars: { GOOSE_THINKING_EFFORT: "minimal" },
|
||||
provider: null,
|
||||
model: null,
|
||||
},
|
||||
runtime: GOOSE_GLOBAL_RUNTIME,
|
||||
scope: "global",
|
||||
});
|
||||
const effortField = getRenderableEffortField(model);
|
||||
assert.ok(effortField, "effort field present");
|
||||
assert.equal(
|
||||
effortField.optionSource,
|
||||
"harnessNative",
|
||||
"minimal: optionSource=harnessNative gates model-heal skip",
|
||||
);
|
||||
assert.equal(
|
||||
effortField.value,
|
||||
null,
|
||||
"minimal is invalid — control shows Inherit, but key survives",
|
||||
);
|
||||
});
|
||||
|
||||
test("buzz_agent_effort_optionSource_is_not_harnessNative_auto_clear_active", () => {
|
||||
// buzz-agent uses the provider/model catalog path (optionSource=buzzAgentCatalog).
|
||||
// isHarnessNativeEffort=false, so model-heal, orphan-clear, and useEffortAutoClear
|
||||
// are all active for buzz-agent — behavior unchanged from main.
|
||||
const BUZZ_AGENT_RUNTIME = {
|
||||
id: "buzz-agent",
|
||||
thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT",
|
||||
acceptedEffortValues: null, // buzz-agent: no static vocab, provider/model-gated
|
||||
providerEnvVar: "BUZZ_AGENT_PROVIDER",
|
||||
modelEnvVar: "BUZZ_AGENT_MODEL",
|
||||
maxTokensEnvVar: null,
|
||||
contextLimitEnvVar: null,
|
||||
maxRoundsEnvVar: null,
|
||||
command: null,
|
||||
defaultArgs: [],
|
||||
};
|
||||
const model = deriveAgentConfigFieldModel({
|
||||
config: {
|
||||
env_vars: { BUZZ_AGENT_THINKING_EFFORT: "high" },
|
||||
provider: "anthropic",
|
||||
model: "claude-3-5-sonnet",
|
||||
},
|
||||
runtime: BUZZ_AGENT_RUNTIME,
|
||||
scope: "global",
|
||||
});
|
||||
const effortField = getRenderableEffortField(model);
|
||||
assert.ok(effortField, "effort field present for buzz-agent");
|
||||
assert.equal(
|
||||
effortField.optionSource,
|
||||
"buzzAgentCatalog",
|
||||
"buzz-agent effort is not harnessNative — auto-clear path active (behavior unchanged)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -264,3 +264,144 @@ test("resolveInheritedRuntimeSubmission agent-local env overrides persona creden
|
||||
"agent-local credential must override persona credential on inherit-transition",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Linked Goose instance: effort persistence and inheritance ─────────────────
|
||||
//
|
||||
// When a linked Goose instance selects Inherit for effort, the record must NOT
|
||||
// acquire the persona's effort key — inheritance happens at spawn time. The
|
||||
// `excludePersonaEnvKeys` parameter strips harness-native effort keys from the
|
||||
// persona layer on the inherit-transition so the record stays override-free.
|
||||
|
||||
const GOOSE_EFFORT_KEY = "GOOSE_THINKING_EFFORT";
|
||||
const BUZZ_EFFORT_KEY = "BUZZ_AGENT_THINKING_EFFORT";
|
||||
// Keys stripped from persona layer for a Goose linked instance.
|
||||
const GOOSE_EXCLUDE_EFFORT_KEYS = [GOOSE_EFFORT_KEY, BUZZ_EFFORT_KEY];
|
||||
|
||||
test("linked_goose_instance_inherit_does_not_materialize_persona_native_effort", () => {
|
||||
// Persona has GOOSE_THINKING_EFFORT=high. User selects Inherit → local envVars
|
||||
// has no effort key. Without excludePersonaEnvKeys the inherited-transition
|
||||
// would persist GOOSE_THINKING_EFFORT=high into the record.
|
||||
const result = resolveInheritedRuntimeSubmission({
|
||||
inheritHarness: true,
|
||||
agentWasHarnessPinned: true,
|
||||
provider: "",
|
||||
personaProvider: "",
|
||||
model: "",
|
||||
personaModel: null,
|
||||
envVars: {}, // user selected Inherit — no local effort override
|
||||
personaEnvVars: { [GOOSE_EFFORT_KEY]: "high" },
|
||||
excludePersonaEnvKeys: GOOSE_EXCLUDE_EFFORT_KEYS,
|
||||
});
|
||||
assert.equal(
|
||||
Object.hasOwn(result.envVars, GOOSE_EFFORT_KEY),
|
||||
false,
|
||||
"persona native effort key must NOT materialize into record on Inherit",
|
||||
);
|
||||
});
|
||||
|
||||
test("linked_goose_instance_inherit_does_not_materialize_persona_legacy_effort", () => {
|
||||
// Same as above but persona has the legacy BUZZ_AGENT_THINKING_EFFORT key
|
||||
// (pre-migration persona). Must also be excluded.
|
||||
const result = resolveInheritedRuntimeSubmission({
|
||||
inheritHarness: true,
|
||||
agentWasHarnessPinned: true,
|
||||
provider: "",
|
||||
personaProvider: "",
|
||||
model: "",
|
||||
personaModel: null,
|
||||
envVars: {},
|
||||
personaEnvVars: { [BUZZ_EFFORT_KEY]: "high" },
|
||||
excludePersonaEnvKeys: GOOSE_EXCLUDE_EFFORT_KEYS,
|
||||
});
|
||||
assert.equal(
|
||||
Object.hasOwn(result.envVars, BUZZ_EFFORT_KEY),
|
||||
false,
|
||||
"persona legacy effort key must NOT materialize into record on Inherit",
|
||||
);
|
||||
});
|
||||
|
||||
test("linked_goose_instance_concrete_effort_override_wins_over_persona", () => {
|
||||
// When the user has a local effort override, it must win over persona — the
|
||||
// exclude list should not strip it (local wins naturally via envVars layering).
|
||||
const result = resolveInheritedRuntimeSubmission({
|
||||
inheritHarness: true,
|
||||
agentWasHarnessPinned: true,
|
||||
provider: "",
|
||||
personaProvider: "",
|
||||
model: "",
|
||||
personaModel: null,
|
||||
envVars: { [GOOSE_EFFORT_KEY]: "medium" }, // user selected a concrete value
|
||||
personaEnvVars: { [GOOSE_EFFORT_KEY]: "high" },
|
||||
excludePersonaEnvKeys: GOOSE_EXCLUDE_EFFORT_KEYS,
|
||||
});
|
||||
assert.equal(
|
||||
result.envVars[GOOSE_EFFORT_KEY],
|
||||
"medium",
|
||||
"local effort override wins over persona",
|
||||
);
|
||||
});
|
||||
|
||||
test("linked_goose_instance_non_effort_persona_env_still_merged", () => {
|
||||
// excludePersonaEnvKeys must not affect non-effort keys — credentials and
|
||||
// other persona env vars must still be included in the inherit-transition.
|
||||
const result = resolveInheritedRuntimeSubmission({
|
||||
inheritHarness: true,
|
||||
agentWasHarnessPinned: true,
|
||||
provider: "",
|
||||
personaProvider: "",
|
||||
model: "",
|
||||
personaModel: null,
|
||||
envVars: {},
|
||||
personaEnvVars: {
|
||||
[GOOSE_EFFORT_KEY]: "high",
|
||||
SOME_OTHER_KEY: "persona_value",
|
||||
},
|
||||
excludePersonaEnvKeys: GOOSE_EXCLUDE_EFFORT_KEYS,
|
||||
});
|
||||
assert.equal(
|
||||
Object.hasOwn(result.envVars, GOOSE_EFFORT_KEY),
|
||||
false,
|
||||
"effort key excluded from persona",
|
||||
);
|
||||
assert.equal(
|
||||
result.envVars.SOME_OTHER_KEY,
|
||||
"persona_value",
|
||||
"non-effort persona env vars still merged",
|
||||
);
|
||||
});
|
||||
|
||||
test("linked_goose_instance_submit_payload_contains_no_inherited_effort_on_inherit", () => {
|
||||
// Full scenario: harness-pinned Goose instance. Persona has GOOSE_THINKING_EFFORT=max.
|
||||
// On inherit-transition (user selects Inherit, clears local effort), the submit
|
||||
// payload must have neither native nor legacy effort key.
|
||||
const result = resolveInheritedRuntimeSubmission({
|
||||
inheritHarness: true,
|
||||
agentWasHarnessPinned: true,
|
||||
provider: "",
|
||||
personaProvider: "anthropic",
|
||||
model: "",
|
||||
personaModel: null,
|
||||
envVars: { ANTHROPIC_API_KEY: "sk-agent" }, // credential is local, no effort
|
||||
personaEnvVars: {
|
||||
ANTHROPIC_API_KEY: "sk-persona",
|
||||
[GOOSE_EFFORT_KEY]: "max",
|
||||
},
|
||||
excludePersonaEnvKeys: GOOSE_EXCLUDE_EFFORT_KEYS,
|
||||
});
|
||||
assert.equal(
|
||||
Object.hasOwn(result.envVars, GOOSE_EFFORT_KEY),
|
||||
false,
|
||||
"no native effort key in submit payload on Inherit",
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(result.envVars, BUZZ_EFFORT_KEY),
|
||||
false,
|
||||
"no legacy effort key in submit payload on Inherit",
|
||||
);
|
||||
// Credential still present (agent's own layer wins over persona).
|
||||
assert.equal(
|
||||
result.envVars.ANTHROPIC_API_KEY,
|
||||
"sk-agent",
|
||||
"agent-local credential preserved",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -175,6 +175,18 @@ export function resolveInheritedRuntimeSubmission(input: {
|
||||
envVars: Record<string, string>;
|
||||
/** The persona's env vars, layered under the agent's own on transition. */
|
||||
personaEnvVars: Record<string, string>;
|
||||
/**
|
||||
* Env-var keys to strip from the persona layer on the inherit-transition.
|
||||
*
|
||||
* Used to prevent harness-native effort keys from materializing into the
|
||||
* record when the user selects Inherit for effort. Effort on a linked
|
||||
* instance is owned by the local record layer; the persona's effort value
|
||||
* is inherited at spawn time without being persisted into the record.
|
||||
*
|
||||
* Pass the runtime's native effort key + `BUZZ_AGENT_THINKING_EFFORT`
|
||||
* when the selected runtime has `acceptedEffortValues`.
|
||||
*/
|
||||
excludePersonaEnvKeys?: readonly string[];
|
||||
}): {
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
@@ -192,12 +204,19 @@ export function resolveInheritedRuntimeSubmission(input: {
|
||||
input.agentWasHarnessPinned &&
|
||||
localProvider.length === 0
|
||||
) {
|
||||
const personaEnvVars = input.excludePersonaEnvKeys?.length
|
||||
? Object.fromEntries(
|
||||
Object.entries(input.personaEnvVars).filter(
|
||||
([k]) => !input.excludePersonaEnvKeys?.includes(k),
|
||||
),
|
||||
)
|
||||
: input.personaEnvVars;
|
||||
return {
|
||||
provider: input.personaProvider.trim() || null,
|
||||
// Fill an empty local model from the persona so a provider-backed runtime
|
||||
// isn't saved model-less; a deliberate local model still wins.
|
||||
model: localModel || input.personaModel?.trim() || null,
|
||||
envVars: { ...input.personaEnvVars, ...input.envVars },
|
||||
envVars: { ...personaEnvVars, ...input.envVars },
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -228,3 +228,102 @@ test("auto-model selection clears the model; concrete selection sets it", () =>
|
||||
);
|
||||
assert.equal(concrete.model, "gpt-5");
|
||||
});
|
||||
|
||||
// ── selectionOnRuntimeChange: effort-key cleanup ──────────────────────────
|
||||
//
|
||||
// At record/persona scope, a runtime change must clean up the previous
|
||||
// runtime's native effort key AND the legacy key (BUZZ_AGENT_THINKING_EFFORT).
|
||||
// The next runtime's native key is also cleared (stale leftover removal).
|
||||
// Unrelated env keys are preserved.
|
||||
|
||||
test("selectionOnRuntimeChange_goose_to_buzz_clears_goose_native_and_legacy", () => {
|
||||
// Goose→buzz-agent: GOOSE_THINKING_EFFORT (prev native) + BUZZ_AGENT_THINKING_EFFORT (legacy)
|
||||
// are deleted. Unrelated keys survive.
|
||||
const current = {
|
||||
...base,
|
||||
envVars: {
|
||||
GOOSE_THINKING_EFFORT: "medium",
|
||||
BUZZ_AGENT_THINKING_EFFORT: "high",
|
||||
SOME_KEY: "value",
|
||||
},
|
||||
};
|
||||
const next = selectionOnRuntimeChange(current, {
|
||||
previousRuntime: "goose",
|
||||
nextRuntime: "buzz-agent",
|
||||
nextRuntimeCanChooseProvider: false,
|
||||
lockedRuntimeReset: "full",
|
||||
previousRuntimeNativeEffortKey: "GOOSE_THINKING_EFFORT",
|
||||
nextRuntimeNativeEffortKey: "BUZZ_AGENT_THINKING_EFFORT",
|
||||
});
|
||||
assert.equal(
|
||||
next.envVars.GOOSE_THINKING_EFFORT,
|
||||
undefined,
|
||||
"prev native effort key must be cleared",
|
||||
);
|
||||
assert.equal(
|
||||
next.envVars.BUZZ_AGENT_THINKING_EFFORT,
|
||||
undefined,
|
||||
"legacy effort key must be cleared",
|
||||
);
|
||||
assert.equal(
|
||||
next.envVars.SOME_KEY,
|
||||
"value",
|
||||
"unrelated env key must survive",
|
||||
);
|
||||
});
|
||||
|
||||
test("selectionOnRuntimeChange_buzz_to_goose_clears_goose_native_and_legacy", () => {
|
||||
// buzz-agent→Goose: previousRuntimeNativeEffortKey is BUZZ_AGENT_THINKING_EFFORT
|
||||
// which equals the legacy key — cleanup fires via the nextRuntimeNativeEffortKey path.
|
||||
const current = {
|
||||
...base,
|
||||
envVars: {
|
||||
GOOSE_THINKING_EFFORT: "low",
|
||||
BUZZ_AGENT_THINKING_EFFORT: "high",
|
||||
OTHER: "keep",
|
||||
},
|
||||
};
|
||||
const next = selectionOnRuntimeChange(current, {
|
||||
previousRuntime: "buzz-agent",
|
||||
nextRuntime: "goose",
|
||||
nextRuntimeCanChooseProvider: true,
|
||||
lockedRuntimeReset: "full",
|
||||
previousRuntimeNativeEffortKey: "BUZZ_AGENT_THINKING_EFFORT",
|
||||
nextRuntimeNativeEffortKey: "GOOSE_THINKING_EFFORT",
|
||||
});
|
||||
// When prev native === legacy key, the else-if branch fires instead:
|
||||
// it clears next native + legacy.
|
||||
assert.equal(
|
||||
next.envVars.GOOSE_THINKING_EFFORT,
|
||||
undefined,
|
||||
"next native effort key must be cleared",
|
||||
);
|
||||
assert.equal(
|
||||
next.envVars.BUZZ_AGENT_THINKING_EFFORT,
|
||||
undefined,
|
||||
"legacy effort key must be cleared",
|
||||
);
|
||||
assert.equal(next.envVars.OTHER, "keep", "unrelated env key must survive");
|
||||
});
|
||||
|
||||
test("selectionOnRuntimeChange_buzz_to_buzz_preserves_effort_keys", () => {
|
||||
// buzz-agent→buzz-agent: no effort cleanup (native === legacy key,
|
||||
// both branches skip). Effort keys survive.
|
||||
const current = {
|
||||
...base,
|
||||
envVars: { BUZZ_AGENT_THINKING_EFFORT: "high" },
|
||||
};
|
||||
const next = selectionOnRuntimeChange(current, {
|
||||
previousRuntime: "buzz-agent",
|
||||
nextRuntime: "buzz-agent",
|
||||
nextRuntimeCanChooseProvider: false,
|
||||
lockedRuntimeReset: "full",
|
||||
previousRuntimeNativeEffortKey: "BUZZ_AGENT_THINKING_EFFORT",
|
||||
nextRuntimeNativeEffortKey: "BUZZ_AGENT_THINKING_EFFORT",
|
||||
});
|
||||
assert.equal(
|
||||
next.envVars.BUZZ_AGENT_THINKING_EFFORT,
|
||||
"high",
|
||||
"buzz-agent effort key preserved on same-runtime switch",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
envVarsClearingManagedApiKey,
|
||||
envVarsWithoutKey,
|
||||
} from "./providerEnvVarUpdates";
|
||||
import { BUZZ_AGENT_THINKING_EFFORT } from "./buzzAgentConfig";
|
||||
|
||||
/**
|
||||
* Pure transition functions for the runtime -> LLM provider -> model dropdown
|
||||
@@ -41,6 +42,19 @@ export function selectionOnRuntimeChange(
|
||||
* only the provider selection ("provider-only").
|
||||
*/
|
||||
lockedRuntimeReset: "full" | "provider-only";
|
||||
/**
|
||||
* Native thinking-effort key for the PREVIOUS runtime, or `null` when the
|
||||
* previous runtime has no effort key or it equals the legacy key.
|
||||
* When provided, this key is deleted from envVars on runtime change.
|
||||
*/
|
||||
previousRuntimeNativeEffortKey?: string | null;
|
||||
/**
|
||||
* Native thinking-effort key for the NEXT runtime, or `null` when the
|
||||
* next runtime has no effort key or it equals the legacy key.
|
||||
* When provided, this key is deleted from envVars on runtime change
|
||||
* (any stale leftover from a prior session is removed).
|
||||
*/
|
||||
nextRuntimeNativeEffortKey?: string | null;
|
||||
},
|
||||
): RuntimeModelProviderSelection {
|
||||
const next = { ...current };
|
||||
@@ -73,6 +87,38 @@ export function selectionOnRuntimeChange(
|
||||
next.provider = "";
|
||||
}
|
||||
|
||||
// Effort cleanup at record/persona scope: clear prev native, next native,
|
||||
// and legacy keys when the runtime changes. Global scope is exempt (Delta-5
|
||||
// rule: global switches preserve both runtimes' native keys — that is handled
|
||||
// by resetConfigForHarnessChange's no-op policy).
|
||||
// Skip when native key equals the legacy key (buzz-agent: they are the same
|
||||
// key so no aliasing applies; no destructive cleanup needed).
|
||||
//
|
||||
// Asymmetric case — prev==legacy + next==null (e.g. buzz→claude): neither
|
||||
// branch fires, so BUZZ_AGENT_THINKING_EFFORT is preserved through the
|
||||
// transition. This is intentional: claude has no effort env key so there is
|
||||
// no stale-alias resurrection risk, and the value is safe to keep for a
|
||||
// future buzz←claude switch. buzz→goose (next != legacy) DOES clear both
|
||||
// keys because a stale BUZZ_AGENT_THINKING_EFFORT would be aliased into
|
||||
// Goose effort on the next spawn.
|
||||
if (
|
||||
params.previousRuntimeNativeEffortKey &&
|
||||
params.previousRuntimeNativeEffortKey !== BUZZ_AGENT_THINKING_EFFORT
|
||||
) {
|
||||
const ev = { ...next.envVars };
|
||||
delete ev[params.previousRuntimeNativeEffortKey];
|
||||
delete ev[BUZZ_AGENT_THINKING_EFFORT];
|
||||
next.envVars = ev;
|
||||
} else if (
|
||||
params.nextRuntimeNativeEffortKey &&
|
||||
params.nextRuntimeNativeEffortKey !== BUZZ_AGENT_THINKING_EFFORT
|
||||
) {
|
||||
const ev = { ...next.envVars };
|
||||
delete ev[params.nextRuntimeNativeEffortKey];
|
||||
delete ev[BUZZ_AGENT_THINKING_EFFORT];
|
||||
next.envVars = ev;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,28 +2,54 @@ import * as React from "react";
|
||||
|
||||
import { useBakedBuildEnvQuery } from "../hooks";
|
||||
import { useGlobalAgentConfig } from "../useGlobalAgentConfig";
|
||||
import { BUZZ_AGENT_THINKING_EFFORT } from "./buzzAgentConfig";
|
||||
import { getInheritedAgentDefaults } from "./bakedEnvHelpers";
|
||||
|
||||
export function useAgentDialogDefaults({
|
||||
inheritedEnvVars = {},
|
||||
open,
|
||||
nativeEffortKey,
|
||||
acceptedEffortValues,
|
||||
}: {
|
||||
inheritedEnvVars?: Record<string, string>;
|
||||
open: boolean;
|
||||
/**
|
||||
* The native effort key for the current runtime
|
||||
* (e.g. `runtime.thinkingEnvVar`). When absent, falls back to the
|
||||
* buzz-agent legacy key — preserving existing behavior for callers that
|
||||
* haven't yet passed runtime metadata.
|
||||
*/
|
||||
nativeEffortKey?: string | null;
|
||||
/**
|
||||
* Canonical effort values for normalization (from `runtime.acceptedEffortValues`).
|
||||
* Passed through to `getInheritedAgentDefaults` so global native effort is
|
||||
* normalized (e.g. xhigh→max) before display. Null for buzz-agent.
|
||||
*/
|
||||
acceptedEffortValues?: readonly string[] | null;
|
||||
}) {
|
||||
const { globalConfig } = useGlobalAgentConfig();
|
||||
const { data: bakedEnv } = useBakedBuildEnvQuery({ enabled: open });
|
||||
const inheritedDefaults = getInheritedAgentDefaults(globalConfig, bakedEnv);
|
||||
const inheritedDefaults = getInheritedAgentDefaults(
|
||||
globalConfig,
|
||||
bakedEnv,
|
||||
nativeEffortKey
|
||||
? { nativeEffortKey, acceptedEffortValues: acceptedEffortValues ?? null }
|
||||
: undefined,
|
||||
);
|
||||
const resolvedEffortKey = nativeEffortKey ?? "BUZZ_AGENT_THINKING_EFFORT";
|
||||
const effectiveInheritedEnvVars = React.useMemo(
|
||||
() => ({
|
||||
...globalConfig.env_vars,
|
||||
...inheritedEnvVars,
|
||||
...(inheritedDefaults.effort.value
|
||||
? { [BUZZ_AGENT_THINKING_EFFORT]: inheritedDefaults.effort.value }
|
||||
? { [resolvedEffortKey]: inheritedDefaults.effort.value }
|
||||
: {}),
|
||||
}),
|
||||
[globalConfig.env_vars, inheritedDefaults.effort.value, inheritedEnvVars],
|
||||
[
|
||||
globalConfig.env_vars,
|
||||
inheritedDefaults.effort.value,
|
||||
inheritedEnvVars,
|
||||
resolvedEffortKey,
|
||||
],
|
||||
);
|
||||
return {
|
||||
globalConfig,
|
||||
|
||||
@@ -62,8 +62,7 @@ type RawFeedItem = {
|
||||
created_at: number;
|
||||
channel_id: string | null;
|
||||
channel_name: string;
|
||||
// Native FeedItemInfo.channel_type is Option<String>: serde emits `null`,
|
||||
// never omits the key.
|
||||
// Native FeedItemInfo.channel_type is Option<String>: serde emits `null`, never omits the key.
|
||||
channel_type: string | null;
|
||||
tags: string[][];
|
||||
category: "mention" | "needs_action" | "activity" | "agent_activity";
|
||||
@@ -161,7 +160,7 @@ export type RawManagedAgent = {
|
||||
auto_restart_on_config_change?: boolean;
|
||||
backend: ManagedAgentBackend;
|
||||
backend_agent_id: string | null;
|
||||
// Pre-feature fixtures may omit these; mapped to "owner-only"/[] in fromRawManagedAgent.
|
||||
// Optional in pre-feature mock fixtures; mapped to "owner-only" / [] in `fromRawManagedAgent`.
|
||||
respond_to?: ManagedAgent["respondTo"];
|
||||
respond_to_allowlist?: string[];
|
||||
};
|
||||
@@ -190,6 +189,7 @@ export type RawAcpRuntimeCatalogEntry = {
|
||||
model_env_var?: string | null;
|
||||
provider_env_var?: string | null;
|
||||
thinking_env_var?: string | null;
|
||||
accepted_effort_values?: string[] | null;
|
||||
max_tokens_env_var?: string | null;
|
||||
context_limit_env_var?: string | null;
|
||||
max_rounds_env_var?: string | null;
|
||||
@@ -749,6 +749,7 @@ export function fromRawAcpRuntimeCatalogEntry(
|
||||
modelEnvVar: entry.model_env_var ?? null,
|
||||
providerEnvVar: entry.provider_env_var ?? null,
|
||||
thinkingEnvVar: entry.thinking_env_var ?? null,
|
||||
acceptedEffortValues: entry.accepted_effort_values ?? null,
|
||||
maxTokensEnvVar: entry.max_tokens_env_var ?? null,
|
||||
contextLimitEnvVar: entry.context_limit_env_var ?? null,
|
||||
maxRoundsEnvVar: entry.max_rounds_env_var ?? null,
|
||||
|
||||
@@ -310,11 +310,8 @@ export type ManagedAgent = {
|
||||
pubkey: string;
|
||||
name: string;
|
||||
personaId: string | null;
|
||||
/**
|
||||
* The record's harness/runtime id (e.g. "goose", "my-custom-harness").
|
||||
* `null` means the agent inherits its harness from the linked persona.
|
||||
* Used to count agents referencing a harness definition (delete confirm).
|
||||
*/
|
||||
/** The record's harness/runtime id (`null` = inherits from persona).
|
||||
* Used to count agents referencing a harness definition (delete confirm). */
|
||||
runtime: string | null;
|
||||
teamId?: string | null;
|
||||
relayUrl: string;
|
||||
@@ -517,6 +514,9 @@ export type AcpRuntimeCatalogEntry = {
|
||||
providerEnvVar: string | null;
|
||||
/** Environment variable used to apply thinking effort, when supported. */
|
||||
thinkingEnvVar: string | null;
|
||||
/** Canonical effort values for runtimes with a static vocabulary (Goose: off|low|medium|high|max);
|
||||
* `null` for buzz-agent (per-model catalog). Single authority for UI, spawn bridge, and reader. */
|
||||
acceptedEffortValues: string[] | null;
|
||||
maxTokensEnvVar: string | null;
|
||||
contextLimitEnvVar: string | null;
|
||||
maxRoundsEnvVar: string | null;
|
||||
|
||||
@@ -7346,6 +7346,12 @@ function withMockRuntimeConfigMetadata(
|
||||
: runtime.id === "buzz-agent"
|
||||
? "BUZZ_AGENT_MAX_ROUNDS"
|
||||
: null,
|
||||
accepted_effort_values:
|
||||
"accepted_effort_values" in runtime
|
||||
? runtime.accepted_effort_values
|
||||
: runtime.id === "goose"
|
||||
? ["off", "low", "medium", "high", "max"]
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user