Files
buzz/desktop/src/features/agents/ui/runtimeModelProviderSelection.test.mjs
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 25dd24a11d 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>
2026-08-04 14:12:32 -04:00

330 lines
10 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
selectionOnModelDropdownChange,
selectionOnProviderDropdownChange,
selectionOnRuntimeChange,
} from "./runtimeModelProviderSelection.ts";
const base = {
provider: "",
model: "",
isCustomProviderEditing: false,
isCustomModelEditing: false,
envVars: {},
};
// --- selectionOnRuntimeChange ---
test("runtime change to a provider-locked runtime, full reset (Persona/Edit): clears provider, custom flags, and managed API key", () => {
const next = selectionOnRuntimeChange(
{
...base,
provider: "anthropic",
model: "claude-4",
isCustomProviderEditing: true,
isCustomModelEditing: true,
envVars: { ANTHROPIC_API_KEY: "sk-1", KEEP: "x" },
},
{
previousRuntime: "buzz-agent",
nextRuntime: "claude",
nextRuntimeCanChooseProvider: false,
lockedRuntimeReset: "full",
},
);
assert.equal(next.provider, "");
assert.equal(next.isCustomProviderEditing, false);
assert.equal(next.isCustomModelEditing, false);
assert.deepEqual(next.envVars, { KEEP: "x" });
});
test("runtime change to a provider-locked runtime, provider-only reset (Create): keeps env vars and custom-model flag", () => {
const next = selectionOnRuntimeChange(
{
...base,
provider: "anthropic",
envVars: { ANTHROPIC_API_KEY: "sk-1" },
isCustomModelEditing: true,
model: "my-custom",
},
{
previousRuntime: "buzz-agent",
nextRuntime: "claude",
nextRuntimeCanChooseProvider: false,
lockedRuntimeReset: "provider-only",
},
);
assert.equal(next.provider, "");
assert.equal(next.isCustomProviderEditing, false);
assert.deepEqual(next.envVars, { ANTHROPIC_API_KEY: "sk-1" });
});
test("runtime change between provider-selection runtimes keeps provider state", () => {
const current = {
...base,
provider: "anthropic",
envVars: { ANTHROPIC_API_KEY: "sk-1" },
};
const next = selectionOnRuntimeChange(current, {
previousRuntime: "goose",
nextRuntime: "buzz-agent",
nextRuntimeCanChooseProvider: true,
lockedRuntimeReset: "full",
});
assert.equal(next.provider, "anthropic");
assert.deepEqual(next.envVars, { ANTHROPIC_API_KEY: "sk-1" });
});
// --- selectionOnProviderDropdownChange ---
test("provider switch clears the previous managed API key and sets the provider", () => {
const next = selectionOnProviderDropdownChange(
{
...base,
provider: "anthropic",
envVars: { ANTHROPIC_API_KEY: "sk-1", KEEP: "x" },
},
{
runtime: "buzz-agent",
nextValue: "openai",
clearModelWhenApiKeyMissing: false,
},
);
assert.equal(next.provider, "openai");
assert.equal(next.isCustomProviderEditing, false);
assert.deepEqual(next.envVars, { KEEP: "x" });
});
test("custom-provider entry clears the managed key and enters custom editing", () => {
const next = selectionOnProviderDropdownChange(
{
...base,
provider: "anthropic",
envVars: { ANTHROPIC_API_KEY: "sk-1" },
},
{
runtime: "buzz-agent",
nextValue: "__custom_provider__",
clearModelWhenApiKeyMissing: false,
},
);
assert.equal(next.isCustomProviderEditing, true);
assert.equal(next.provider, "");
assert.deepEqual(next.envVars, {});
});
test("auto-provider selection maps to empty provider", () => {
const next = selectionOnProviderDropdownChange(
{ ...base, provider: "anthropic", envVars: { ANTHROPIC_API_KEY: "sk-1" } },
{
runtime: "buzz-agent",
nextValue: "__auto_provider__",
clearModelWhenApiKeyMissing: false,
},
);
assert.equal(next.provider, "");
assert.deepEqual(next.envVars, {});
});
test("Persona mode clears the model when the new provider's API key is missing", () => {
const next = selectionOnProviderDropdownChange(
{ ...base, model: "claude-4", provider: "" },
{
runtime: "buzz-agent",
nextValue: "anthropic",
clearModelWhenApiKeyMissing: true,
},
);
assert.equal(next.model, "");
});
test("Create/Edit mode keeps the model when the new provider's API key is missing", () => {
// claude-4 is scope-agnostic here: shouldClearKnownModelForSelectionScope
// only clears known models for the selection scope, and a custom string
// stays put — mirroring the dialogs' behavior without the persona flag.
const next = selectionOnProviderDropdownChange(
{ ...base, model: "my-custom-model", provider: "" },
{
runtime: "buzz-agent",
nextValue: "anthropic",
clearModelWhenApiKeyMissing: false,
},
);
assert.equal(next.model, "my-custom-model");
});
test("custom-model editing suppresses the model-scope clear on provider switch", () => {
const next = selectionOnProviderDropdownChange(
{ ...base, model: "anything", isCustomModelEditing: true },
{
runtime: "buzz-agent",
nextValue: "openai",
clearModelWhenApiKeyMissing: false,
},
);
assert.equal(next.model, "anything");
assert.equal(next.isCustomModelEditing, true);
});
// --- selectionOnModelDropdownChange ---
test("custom-model entry with clear (Persona) drops a known model", () => {
const next = selectionOnModelDropdownChange(
{ ...base, model: "known-model" },
{
nextValue: "__custom_model__",
clearKnownModelOnCustomEntry: true,
isModelCustom: false,
},
);
assert.equal(next.isCustomModelEditing, true);
assert.equal(next.model, "");
});
test("custom-model entry keeps an already-custom model (Persona) and any model (Edit)", () => {
const personaCustom = selectionOnModelDropdownChange(
{ ...base, model: "already-custom" },
{
nextValue: "__custom_model__",
clearKnownModelOnCustomEntry: true,
isModelCustom: true,
},
);
assert.equal(personaCustom.model, "already-custom");
const edit = selectionOnModelDropdownChange(
{ ...base, model: "known-model" },
{
nextValue: "__custom_model__",
clearKnownModelOnCustomEntry: false,
isModelCustom: false,
},
);
assert.equal(edit.model, "known-model");
assert.equal(edit.isCustomModelEditing, true);
});
test("auto-model selection clears the model; concrete selection sets it", () => {
const auto = selectionOnModelDropdownChange(
{ ...base, model: "old", isCustomModelEditing: true },
{
nextValue: "__auto_model__",
clearKnownModelOnCustomEntry: false,
isModelCustom: false,
},
);
assert.equal(auto.model, "");
assert.equal(auto.isCustomModelEditing, false);
const concrete = selectionOnModelDropdownChange(
{ ...base, model: "" },
{
nextValue: "gpt-5",
clearKnownModelOnCustomEntry: false,
isModelCustom: false,
},
);
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",
);
});