diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 824ee3cfc9..bb26f55880 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -168,6 +168,7 @@ export default defineConfig({
"**/agent-lifecycle-feedback.spec.ts",
"**/agent-access-warning.spec.ts",
"**/edit-agent-run-on.spec.ts",
+ "**/claude-effort-picker.spec.ts",
"**/inbox-live-update.spec.ts",
"**/mesh-compute.spec.ts",
"**/observer-archive-policy.spec.ts",
diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs
index bc2bed54e8..6d059e2700 100644
--- a/desktop/src-tauri/src/commands/agent_models_update.rs
+++ b/desktop/src-tauri/src/commands/agent_models_update.rs
@@ -118,8 +118,8 @@ pub(crate) fn stamp_record_updated_at(
}
/// Flush a retained managed-agent policy, preserving any earlier profile error.
-pub(crate) async fn flush_managed_agent_policy(
- app: &AppHandle,
+pub(crate) async fn flush_managed_agent_policy(
+ app: &AppHandle,
state: &AppState,
existing_error: Option,
) -> Option {
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index 288334d750..ba28ba67c0 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -36,8 +36,8 @@ pub(crate) use pending::{retain_managed_agent_pending, tombstone_managed_agent_p
/// For one-shot command paths only — the 5s list poll calls
/// `build_managed_agent_summary` directly with stores loaded once per call,
/// not once per record.
-pub(super) fn summarize_from_disk(
- app: &AppHandle,
+pub(super) fn summarize_from_disk(
+ app: &AppHandle,
record: &ManagedAgentRecord,
runtimes: &std::collections::HashMap<
crate::managed_agents::ManagedAgentRuntimeKey,
@@ -393,6 +393,14 @@ pub async fn create_managed_agent(
input: CreateManagedAgentRequest,
app: AppHandle,
state: State<'_, AppState>,
+) -> Result {
+ create_managed_agent_in(input, app, &state).await
+}
+
+async fn create_managed_agent_in(
+ input: CreateManagedAgentRequest,
+ app: AppHandle,
+ state: &AppState,
) -> Result {
let name = input.name.trim().to_string();
let requested_persona_id = input
@@ -649,7 +657,7 @@ pub async fn create_managed_agent(
input.parallelism,
linked_persona.as_ref(),
)?;
- let record = ManagedAgentRecord {
+ let mut record = ManagedAgentRecord {
pubkey: pubkey.clone(),
name: name.clone(),
description: None,
@@ -743,7 +751,9 @@ pub async fn create_managed_agent(
},
effort_level: None,
};
-
+ if let Some(level) = input.effort_level.clone() {
+ super::agent_config::apply_picker_effort_level(&mut record, Some(level));
+ }
records.push(record);
save_managed_agents(&app, &records)?;
@@ -755,7 +765,7 @@ pub async fn create_managed_agent(
// Publish the agent to the relay. Inside the Phase-3 lock, after save,
// before any .await — owner-authored, every agent (Will's ruling: no
// is_builtin/persona-membership gate).
- retain_managed_agent_pending(&app, &state, record);
+ retain_managed_agent_pending(&app, state, record);
// Effective owner-authored description for the kind:0 `about`.
let profile_about = crate::managed_agents::record_effective_description(record, &personas);
(
@@ -768,8 +778,7 @@ pub async fn create_managed_agent(
// ── Phase 3b: local spawn (async preflight outside store lock) ───────────
let mut spawn_error = None;
let agent = if input.spawn_after_create && input.backend == BackendKind::Local {
- match start_local_agent_with_preflight(&app, &state, &pubkey, true, None, None, None).await
- {
+ match start_local_agent_with_preflight(&app, state, &pubkey, true, None, None, None).await {
Ok(agent) => agent,
Err(error) => {
let _store_guard = state
@@ -803,7 +812,7 @@ pub async fn create_managed_agent(
// Use the avatar persisted on the record so the published profile and any
// later reconciliation agree on the same value.
let mut profile_sync_error = profile::publish_agent_profile_with_about(
- &state,
+ state,
&resolved_relay_url,
&agent_keys,
&name,
@@ -813,7 +822,7 @@ pub async fn create_managed_agent(
)
.await;
profile_sync_error =
- super::agent_models::flush_managed_agent_policy(&app, &state, profile_sync_error).await;
+ super::agent_models::flush_managed_agent_policy(&app, state, profile_sync_error).await;
let spawn_error = if input.spawn_after_create && input.backend != BackendKind::Local {
if let BackendKind::Provider { ref id, ref config } = input.backend {
@@ -827,10 +836,10 @@ pub async fn create_managed_agent(
.iter()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| "agent disappeared".to_string())?;
- build_deploy_payload(&app, &state, rec)?
+ build_deploy_payload(&app, state, rec)?
};
match deploy_to_provider(
- &app, &state, &pubkey, id, config, agent_json, None, None, None, None,
+ &app, state, &pubkey, id, config, agent_json, None, None, None, None,
)
.await
{
diff --git a/desktop/src-tauri/src/commands/agents/provider_deploy.rs b/desktop/src-tauri/src/commands/agents/provider_deploy.rs
index 15db4dec5a..2dd740c58d 100644
--- a/desktop/src-tauri/src/commands/agents/provider_deploy.rs
+++ b/desktop/src-tauri/src/commands/agents/provider_deploy.rs
@@ -39,8 +39,8 @@ use super::build_deploy_payload;
/// message exactly like a local spawn. Per-invocation only — never persisted
/// on the record, so later redeploys do not carry a stale floor.
#[allow(clippy::too_many_arguments)]
-pub(crate) async fn deploy_to_provider(
- app: &AppHandle,
+pub(crate) async fn deploy_to_provider(
+ app: &AppHandle,
state: &AppState,
pubkey: &str,
_provider_id: &str,
diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs
index f56ce77de3..f1979d8afc 100644
--- a/desktop/src-tauri/src/commands/agents_tests.rs
+++ b/desktop/src-tauri/src/commands/agents_tests.rs
@@ -793,3 +793,91 @@ fn owner_only_access_deploy_payload_clamps_stale_access() {
"owner-only-access deploy payload retained a stale allowlist"
);
}
+
+/// Runs the real create in a child process: temp app-data and nest paths,
+/// keychain use turned off so agent keys stay inline in the temp agent file,
+/// and no background work outliving the test.
+#[test]
+fn create_managed_agent_persists_picked_effort_and_drops_env_aliases() {
+ const CHILD: &str = "BUZZ_CREATE_EFFORT_TEST_HOME";
+ if let Some(home) = std::env::var_os(CHILD) {
+ return create_with_effort_in_confined_child(std::path::Path::new(&home));
+ }
+ let home = tempfile::tempdir().unwrap();
+ let output = std::process::Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", "commands::agents::tests::create_managed_agent_persists_picked_effort_and_drops_env_aliases", "--nocapture"])
+ .env(CHILD, home.path()).env("HOME", home.path())
+ .env("XDG_DATA_HOME", home.path()).env("APPDATA", home.path())
+ .env("LOCALAPPDATA", home.path())
+ .env_remove("BUZZ_PRIVATE_KEY").env_remove("BUZZ_AUTH_TAG")
+ .env_remove("BUZZ_RELAY_URL").env_remove("BUZZ_NETWORK_TRACE")
+ .output().unwrap();
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(
+ output.status.success() && stdout.contains("1 passed"),
+ "child failed: {stdout}\n{}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+}
+
+fn create_with_effort_in_confined_child(home: &std::path::Path) {
+ use tauri::Manager;
+ crate::managed_agents::storage::NO_KEYCHAIN_FOR_TEST
+ .store(true, std::sync::atomic::Ordering::SeqCst);
+ crate::managed_agents::pin_nest_dir_for_test(home.join("nest"));
+ // Windows known-folder APIs ignore HOME/APPDATA; an absolute mock
+ // identifier replaces the app-data base on every platform.
+ let app_data = home.join("app-data");
+ let mut context = tauri::test::mock_context(tauri::test::noop_assets());
+ context.config_mut().identifier = app_data.to_str().unwrap().to_owned();
+
+ // Unroutable relay: profile publish fails fast and is reported, not fatal.
+ const RELAY: &str = "ws://127.0.0.1:9";
+ let state = crate::app_state::build_app_state();
+ *state.relay_url_override.lock().unwrap() = Some(RELAY.to_string());
+ let app = tauri::test::mock_builder()
+ .manage(state)
+ .build(context)
+ .expect("mock app builds headless");
+ assert_eq!(app.path().app_data_dir().unwrap(), app_data);
+ let alias = crate::managed_agents::config_bridge::effort::effort_suppress_keys()[0];
+ let create = |name: &str, effort: Option<&str>| {
+ let input: CreateManagedAgentRequest = serde_json::from_value(serde_json::json!({
+ "name": name, "relayUrl": RELAY, "acpCommand": null,
+ "agentCommand": null, "idleTimeoutSeconds": null,
+ "maxTurnDurationSeconds": null, "parallelism": null,
+ "systemPrompt": null, "avatarUrl": null, "model": null,
+ "provider": null, "effortLevel": effort,
+ "envVars": { alias: "low" }, "spawnAfterCreate": false,
+ }))
+ .unwrap();
+ let state = app.state::();
+ tauri::async_runtime::block_on(create_managed_agent_in(input, app.handle().clone(), &state))
+ .expect("create succeeds")
+ .agent
+ .pubkey
+ };
+ let picked = create("Picked", Some("high"));
+ let unpicked = create("Unpicked", None);
+ let records = load_managed_agents(app.handle()).unwrap();
+
+ let find = |pubkey: &str| records.iter().find(|r| r.pubkey == pubkey).unwrap();
+ assert_eq!(find(&picked).effort_level.as_deref(), Some("high"));
+ assert!(!find(&picked).env_vars.contains_key(alias));
+ assert_eq!(find(&unpicked).effort_level, None);
+ assert!(
+ find(&unpicked).env_vars.contains_key(alias),
+ "no pick leaves env alone"
+ );
+ // The keychain step was skipped: both keys are still inline on disk.
+ let path = crate::managed_agents::storage::managed_agents_store_path(app.handle()).unwrap();
+ let raw: Vec =
+ serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
+ for pubkey in [&picked, &unpicked] {
+ let entry = raw.iter().find(|r| r["pubkey"] == pubkey.as_str()).unwrap();
+ assert!(entry["private_key_nsec"]
+ .as_str()
+ .unwrap()
+ .starts_with("nsec1"));
+ }
+}
diff --git a/desktop/src-tauri/src/commands/managed_agent_definition.rs b/desktop/src-tauri/src/commands/managed_agent_definition.rs
index 3275380748..e26a4f8c4e 100644
--- a/desktop/src-tauri/src/commands/managed_agent_definition.rs
+++ b/desktop/src-tauri/src/commands/managed_agent_definition.rs
@@ -1,12 +1,15 @@
//! Managed-agent definition validation at local mutation boundaries.
-use crate::managed_agents::{CreateManagedAgentRequest, ManagedAgentRecord};
+use crate::managed_agents::{BackendKind, CreateManagedAgentRequest, ManagedAgentRecord};
pub(super) fn validate_create_definition(
name: &str,
persona_id: Option<&str>,
input: &CreateManagedAgentRequest,
) -> Result<(), String> {
+ if input.effort_level.is_some() && input.backend != BackendKind::Local {
+ return Err("remote effort is set at deploy time".to_string());
+ }
validate_definition_fields(name, persona_id, input.system_prompt.as_deref())
}
@@ -96,6 +99,23 @@ mod tests {
}
}
+ #[test]
+ fn create_rejects_effort_for_non_local_backend_only() {
+ let mut input = create_request("Review code.");
+ input.effort_level = Some("high".to_string());
+ validate_create_definition("Reviewer", None, &input).expect("local effort is accepted");
+ input.backend = BackendKind::Provider {
+ id: "provider".into(),
+ config: serde_json::json!({}),
+ };
+ let error = validate_create_definition("Reviewer", None, &input)
+ .expect_err("provider create must reject effort before any side effect");
+ assert!(error.contains("deploy time"), "unexpected error: {error}");
+ input.effort_level = None;
+ validate_create_definition("Reviewer", None, &input)
+ .expect("provider create without effort is unchanged");
+ }
+
#[test]
fn create_accepts_visible_multiline_definition_less_prompt() {
let input = create_request("Review changes.\n\tCall out security risks.");
diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs
index 9b308dad0e..8ce221b85b 100644
--- a/desktop/src-tauri/src/managed_agents/nest.rs
+++ b/desktop/src-tauri/src/managed_agents/nest.rs
@@ -89,6 +89,15 @@ pub fn init_nest_dir(is_dev: bool) {
let _ = NEST_DIR.set(path);
}
+/// Pin the nest root for a child-process test so background nest work cannot
+/// reach the user's real nest.
+#[cfg(test)]
+pub(crate) fn pin_nest_dir_for_test(path: PathBuf) {
+ NEST_DIR
+ .set(Some(path))
+ .expect("nest dir pinned before first use");
+}
+
/// Returns the nest root path (`~/.buzz` for prod, `~/.buzz-dev` for dev),
/// or `None` if the home directory cannot be resolved.
///
diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs
index e2ed83f39b..6457cb78fd 100644
--- a/desktop/src-tauri/src/managed_agents/persona_events.rs
+++ b/desktop/src-tauri/src/managed_agents/persona_events.rs
@@ -312,8 +312,8 @@ pub async fn flush_pending_events(
/// The scope snapshots its relay, owner keys, and database path together
/// before network work starts. Switching communities during the flush cannot
/// redirect rows from the old scope into the new relay.
-pub async fn flush_active_pending_events(
- app: &tauri::AppHandle,
+pub async fn flush_active_pending_events(
+ app: &tauri::AppHandle,
state: &AppState,
) -> Result {
let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs
index 6090d47d8a..5ae1b80fb6 100644
--- a/desktop/src-tauri/src/managed_agents/storage.rs
+++ b/desktop/src-tauri/src/managed_agents/storage.rs
@@ -19,12 +19,22 @@ fn agent_keyring_name(pubkey: &str) -> String {
format!("agent:{pubkey}")
}
+/// Process-wide: a confined child-process test turns this on so agent keys
+/// stay inline, as in a build without a keychain.
+#[cfg(test)]
+pub(crate) static NO_KEYCHAIN_FOR_TEST: std::sync::atomic::AtomicBool =
+ std::sync::atomic::AtomicBool::new(false);
+
/// The agent secret store. `None` when the build has no keyring backend, in
/// which case agent keys stay inline in the `0o600` JSON file. Uses
/// `SecretStore::shared` so identity and agent callers share one instance —
/// and therefore one in-memory cache and one mutex — preventing last-writer-wins
/// races on concurrent blob writes.
fn agent_secret_store() -> Option<&'static SecretStore> {
+ #[cfg(test)]
+ if NO_KEYCHAIN_FOR_TEST.load(std::sync::atomic::Ordering::SeqCst) {
+ return None;
+ }
if cfg!(feature = "system-keyring") {
Some(SecretStore::shared(keyring_service()))
} else {
diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs
index e31b043253..5e82c65f48 100644
--- a/desktop/src-tauri/src/managed_agents/types/requests.rs
+++ b/desktop/src-tauri/src/managed_agents/types/requests.rs
@@ -204,6 +204,9 @@ pub struct CreateManagedAgentRequest {
pub respond_to_allowlist: Vec,
#[serde(default)]
pub relay_mesh: Option,
+ /// Local-only thinking effort saved before the first spawn.
+ #[serde(default)]
+ pub effort_level: Option,
}
/// Patch request for updating a managed agent's mutable fields.
diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md
index 17a52d898d..a9e6a73dd1 100644
--- a/desktop/src/features/agents/AGENTS.md
+++ b/desktop/src/features/agents/AGENTS.md
@@ -53,6 +53,12 @@ with a TypeScript lookup table or an id comparison in a component.
`ownedByModelId`; Claude effort is `deferredUntilNativeOptionsAvailable`.
New absences get new named reasons in `AgentConfigOmission` /
`render` — never a `showX` prop.
+ Known exception, pending follow-up: the effort write control (rule 14)
+ derives Claude's levels in `ui/effortPicker.ts`, including a TypeScript
+ table for Claude Code's model aliases (`default`, `opus`, `opusplan`,
+ `sonnet`), while this descriptor still says
+ `deferredUntilNativeOptionsAvailable`. Move those levels into the runtime
+ catalog or model data rather than extending the table.
4. **The clearing policy is the named types.** `onContextChange:
"resetDependentValues"` (user changed harness/provider → dependent values
reset everywhere) vs `onCatalogMismatch: "explainOnly" | "onboardingCleanup"`
@@ -223,23 +229,35 @@ with a TypeScript lookup table or an id comparison in a component.
[the provenance contract](../../../../docs/agent-management-provenance.md).
14. **Thinking effort has two surfaces: a local-only WRITE control and a
read-only two-facts DISPLAY.** The write control is `EffortPickerField`
- (`ui/EffortPickerField.tsx`), a self-contained section component mounted in
- `AgentInstanceEditDialog` beside the Model block. It is **Save-gated, not
- direct-write**: the control is fully controlled by the parent dialog
- (`value`/`onChange`) and owns no mutation. The dialog persists the selection
- by embedding `effortLevel` in the locked `update_managed_agent` IPC call, so
+ (`ui/EffortPickerField.tsx`), mounted in `AgentInstanceEditDialog` beside
+ the Model block and in the create dialog (`AgentDialog`, local agents
+ only). It is **Save-gated, not direct-write**: the control is fully
+ controlled by the parent dialog (`value`/`onChange`) and owns no mutation.
+ Edit embeds `effortLevel` in the locked `update_managed_agent` IPC call, so
the effort write is atomic with any access-policy change and can never race
- or survive a Cancel or failed Save. There is no standalone
- `persistAgentEffortLevel` setter. Its gating and option compute live in the
- pure helper `ui/effortPicker.ts` (`effortPickerState`): the picker renders
- only when `agent.backend.type === "local"` **AND** a `thought_level`
- `effortConfigId` has been discovered from the running session (absent
- pre-first-session and for runtimes/models without effort support). Local-only
+ or survive a Cancel or failed Save; Create passes it to
+ `create_managed_agent`, which sets it when it builds the record. There is
+ no standalone `persistAgentEffortLevel` setter. Gating and option compute
+ live in the pure helper `ui/effortPicker.ts`: `effortChoices` returns the
+ levels the model that will run offers, and `effortPickerState` renders only
+ when `agent.backend.type === "local"` **AND** levels are known or a level
+ is stored. Claude's levels come from the model data for the model Claude
+ will launch: the explicit or linked-definition model, then the structured
+ global model, then the adapter default from model discovery — never baked
+ `BUZZ_AGENT_MODEL` or provider fallbacks, which Claude never launches with,
+ and never the stored session, which does not record which model produced
+ it. While that model is unknown (`EFFORT_LEVELS_UNKNOWN`) the picker hides
+ but a pending pick is kept. Other runtimes use the running session's
+ `thought_level` list while the runtime is unchanged (absent
+ pre-first-session). A stored level is only the agent's own saved value
+ (`ownEffortLevel`, origin `buzzExplicit`) — never a session, inherited, or
+ config-file value the dialog cannot clear. It stays shown and clearable
+ across model and runtime switches, even when the model doesn't list it,
+ and is hidden only on the pin→inherit transition, which clears it. Local-only
is load-bearing, not cosmetic — the Rust command rejects non-local backends
- because remote effort is set at deploy time via `policy_env`. Because the
- control reads its inputs from the config surface the dialog already fetches
- (`useAgentConfigSurface`), it integrates into the dialog's existing field
- group without additional IPC. The read-only display is the `thinkingEffort`
+ because remote effort is set at deploy time via `policy_env`. The control
+ reads the config surface, model discovery, and global config the dialogs
+ already fetch, so it adds no IPC of its own. The read-only display is the `thinkingEffort`
normalized field rendered by `AgentConfigPanel` via `NormalizedRow`, which
already shows both facts — `field.value` (canonical, the effort the next
spawn will launch with) and, when a running ACP session differs,
@@ -386,10 +404,11 @@ buzz messages send --channel --reply-to \
- `ui/acpCommandPicker.test.mjs` — stock/discovered/unavailable command mode,
late discovery, query-failure compatibility, and conventional replacement of
persisted unknown commands.
-- `ui/effortPicker.test.mjs` — `effortPickerState` gating (local + discovered
- `effortConfigId` renders; provider backend or missing configId hides) and
- option/preselect compute, plus `effortSelectionToPersistedValue` sentinel →
- null. This is where the v4 provider regression is pinned: the write control
+- `ui/effortPicker.test.mjs` — `effortChoices` level resolution (Claude model
+ data and aliases, unknown model, other runtimes' session list),
+ `effortPickerState` gating (local + known levels or a stored level renders;
+ provider backend hides) and option/preselect/note compute, `ownEffortLevel`,
+ and `effortSelectionToPersistedValue` sentinel → null. This is where the v4 provider regression is pinned: the write control
must never render for a provider backend.
- `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` — onboarding behavior
acceptance coverage for readiness, failure states, defaults, session-draft
diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.ts b/desktop/src/features/agents/lib/instanceInputForDefinition.ts
index 77942aba1c..0db8d0a293 100644
--- a/desktop/src/features/agents/lib/instanceInputForDefinition.ts
+++ b/desktop/src/features/agents/lib/instanceInputForDefinition.ts
@@ -112,6 +112,7 @@ export async function buildInstanceInputForDefinition(
runtime: AcpRuntime,
upload?: UploadMediaBytes,
backendIntent?: BackendIntent,
+ effortLevel?: string | null,
): Promise {
const avatarUrl = await resolveManagedAgentAvatarUrl(
persona.avatarUrl,
@@ -157,5 +158,6 @@ export async function buildInstanceInputForDefinition(
spawnAfterCreate: true,
startOnAppLaunch: true,
backend: { type: "local" },
+ ...(effortLevel && { effortLevel }),
};
}
diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts
index b945496380..7720bcbd9d 100644
--- a/desktop/src/features/agents/observerRelayStore.ts
+++ b/desktop/src/features/agents/observerRelayStore.ts
@@ -520,8 +520,10 @@ function processLiveObserverEvents(
}
}
if (parsed.kind === "session_config_captured") {
- void putAgentSessionConfig(agentPubkey, parsed.payload);
- onSessionConfigCaptured?.(agentPubkey);
+ // Refresh only after the store settles, so readers see the new options.
+ void putAgentSessionConfig(agentPubkey, parsed.payload)
+ .catch((error) => console.warn("Session config not stored:", error))
+ .then(() => onSessionConfigCaptured?.(agentPubkey));
} else if (parsed.kind === "control_result") {
// Thread the envelope's channelId into the frame so the ModelPicker can
// count a terminal switch result once per distinct channel.
diff --git a/desktop/src/features/agents/sessionConfigCapture.test.mjs b/desktop/src/features/agents/sessionConfigCapture.test.mjs
new file mode 100644
index 0000000000..23ddf1f64c
--- /dev/null
+++ b/desktop/src/features/agents/sessionConfigCapture.test.mjs
@@ -0,0 +1,82 @@
+/**
+ * A captured session config is stored only from live observer events, and the
+ * config-surface refresh waits for that store so readers see the new options.
+ */
+import assert from "node:assert/strict";
+import { beforeEach, test } from "node:test";
+
+import {
+ _testProcessLiveObserverEvents,
+ _testRegisterKnownAgents,
+ ingestArchivedObserverEvents,
+ resetAgentObserverStore,
+ setSessionConfigCapturedCallback,
+} from "@/features/agents/observerRelayStore.ts";
+
+const AGENT = "a".repeat(64);
+const log = [];
+let settleStore = () => {};
+
+globalThis.window ??= globalThis;
+globalThis.window.__TAURI_INTERNALS__ = {
+ invoke: (cmd) => {
+ log.push(cmd);
+ if (cmd !== "put_agent_session_config") return Promise.resolve(null);
+ return new Promise((resolve) => {
+ settleStore = () => {
+ log.push("stored");
+ resolve(null);
+ };
+ });
+ },
+ transformCallback: () => 0,
+};
+
+const captured = {
+ seq: 1,
+ timestamp: "2026-01-01T00:00:01.000Z",
+ kind: "session_config_captured",
+ agentIndex: 0,
+ channelId: "chan-1",
+ sessionId: "sess-1",
+ turnId: null,
+ payload: { configOptions: [] },
+};
+
+beforeEach(() => {
+ resetAgentObserverStore();
+ log.length = 0;
+ setSessionConfigCapturedCallback(() => log.push("refresh"));
+});
+
+test("live capture refreshes only after the store write settles", async () => {
+ _testProcessLiveObserverEvents(AGENT, [captured]);
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ assert.deepEqual(log, ["put_agent_session_config"]);
+
+ settleStore();
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ assert.deepEqual(log, ["put_agent_session_config", "stored", "refresh"]);
+});
+
+test("archived capture is read-only: no store write and no refresh", async () => {
+ _testRegisterKnownAgents("sub", [AGENT]);
+ const raw = {
+ id: "e".repeat(64),
+ pubkey: AGENT,
+ created_at: 1000,
+ kind: 24200,
+ tags: [
+ ["p", "b".repeat(64)],
+ ["agent", AGENT],
+ ["frame", "telemetry"],
+ ],
+ content: "encrypted",
+ sig: "s".repeat(128),
+ };
+ for (const event of [captured, { ...captured, channelId: null }]) {
+ await ingestArchivedObserverEvents([raw], () => Promise.resolve(event));
+ }
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ assert.deepEqual(log, []);
+});
diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx
index 804fd5aad3..3c8efcb613 100644
--- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx
+++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx
@@ -66,6 +66,7 @@ import {
MODEL_DISCOVERY_LOADING_VALUE,
usePersonaModelDiscovery,
} from "./usePersonaModelDiscovery";
+import { type EffortOptions, effortChoices } from "./effortPicker";
import {
useAcpCommandsQuery,
useBakedBuildEnvKeysQuery,
@@ -114,12 +115,15 @@ type AgentDefinitionDialogProps = {
) => Promise;
/** Publishes saved changes when the edited agent is shared in the catalog. */
publishCatalogUpdatesOnSave?: boolean;
- createRunSection?: React.ReactNode;
+ /** Receives the effort levels allowed by the model the form will create. */
+ createRunSection?: (effortOptions: EffortOptions) => React.ReactNode;
/** Extra create-mode submit gate (e.g. incomplete provider config). */
createSubmitBlocked?: boolean;
};
export type AgentDefinitionSubmitOptions = {
publishCatalogUpdates: boolean;
+ /** Create mode: the effort levels shown to `createRunSection`. */
+ effortOptions?: EffortOptions;
};
export function AgentDefinitionDialog({
open,
@@ -376,7 +380,7 @@ export function AgentDefinitionDialog({
);
return;
}
- await onSubmit(baseInput, { publishCatalogUpdates: false });
+ await onSubmit(baseInput, { publishCatalogUpdates: false, effortOptions });
}
function handleSubmitForm(event: React.FormEvent) {
@@ -510,6 +514,7 @@ export function AgentDefinitionDialog({
discoveredModelOptions,
modelDiscoveryLoading,
modelDiscoveryStatus,
+ agentDefaultModel,
} = usePersonaModelDiscovery({
envVars: envVarsForDiscovery,
isCustomProviderEditing,
@@ -612,6 +617,17 @@ export function AgentDefinitionDialog({
{runtimeWarningText} Visit Settings > Agents to set it up.
) : null;
+ const effortOptions = effortChoices({
+ runtimeId: runtime,
+ models: [
+ model,
+ globalConfig.model, // build/provider fallbacks never reach Claude
+ agentDefaultModel,
+ ],
+ sessionApplies: false,
+ });
+ const createRunSectionNode =
+ isCreateMode && createRunSection?.(effortOptions);
const advancedFieldsTransition = shouldReduceMotion
? { duration: 0 }
: ADVANCED_FIELDS_MOTION_TRANSITION;
@@ -963,7 +979,7 @@ export function AgentDefinitionDialog({
transition={advancedFieldsTransition}
>
Promise;
};
@@ -135,28 +138,42 @@ function AgentCreateDialogRouter({
onSubmitDefinition,
}: AgentDialogCreateProps) {
const [runDraft, setRunDraft] = React.useState(emptyWhereToRunDraft);
+ const [effortLevel, setEffortLevel] = React.useState(null);
const initialValues = React.useMemo(
() => providedInitialValues ?? createPersonaDialogState().initialValues,
[providedInitialValues],
);
const copy = createPersonaDialogState();
+ // Effort is local-only: a provider backend sets it at deploy time.
+ const backendIntent = resolveBackendIntent(runDraft);
return (
// The create flow is the one surface that knows where the agent will run,
// because it owns the "Run on" draft.
{
- setRunDraft(nextDraft);
- onDirtyChange?.(true);
- }}
- />
- }
+ createRunSection={(effortOptions) => (
+ <>
+ {
+ setRunDraft(nextDraft);
+ onDirtyChange?.(true);
+ }}
+ />
+ {backendIntent ? null : (
+
+ )}
+ >
+ )}
createSubmitBlocked={!canSubmitWhereToRun(runDraft)}
description={copy.description}
embedded={embedded}
@@ -165,11 +182,14 @@ function AgentCreateDialogRouter({
isPending={isDefinitionPending}
onDirtyChange={onDirtyChange}
onOpenChange={onOpenChange}
- onSubmit={async (input) => {
+ onSubmit={async (input, { effortOptions }) => {
const submitted = await onSubmitDefinition(
input,
"definition_start",
- resolveBackendIntent(runDraft),
+ backendIntent,
+ !backendIntent && isSavableEffort(effortLevel, effortOptions)
+ ? effortLevel
+ : null,
);
if (submitted) {
onDirtyChange?.(false);
diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
index 8c0e95df35..1324f5db4b 100644
--- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
+++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
@@ -29,6 +29,7 @@ import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { setManagedAgentAutoRestart } from "@/shared/api/tauriManagedAgents";
+import { effortChoices, isSavableEffort, ownEffortLevel } from "./effortPicker";
import { EffortPickerField } from "./EffortPickerField";
import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields";
import {
@@ -153,13 +154,10 @@ export function AgentInstanceEditDialog({
const [envVars, setEnvVars] = React.useState(agent.envVars);
const [autoRestartOnConfigChange, setAutoRestartOnConfigChange] =
React.useState(agent.autoRestartOnConfigChange);
- // Effort picker is Save-gated: hold the pending selection in dialog state and
- // embed it in the locked update payload on Save alone (see
- // resolveEffortSubmission / handleSubmit — PR #4625), never on selection.
- // `effortTouched` distinguishes "user picked a value" from "showing the
- // config-surface effective value", so an untouched Save writes nothing.
- const [effortLevel, setEffortLevel] = React.useState(null);
- const effortTouched = React.useRef(false);
+ // Save-gated effort (PR #4625); untouched Saves write nothing.
+ // `undefined` means untouched; `null` means cleared to the adapter default.
+ const [effortLevel, setEffortLevel] = React.useState();
+ const effortTouched = effortLevel !== undefined;
const personasQuery = usePersonasQuery();
const linkedPersona = React.useMemo(
() =>
@@ -189,6 +187,14 @@ export function AgentInstanceEditDialog({
// Tracks whether the user has made an in-dialog runtime selection.
const runtimeTouched = React.useRef(false);
+ const savedRuntime = React.useMemo(
+ () =>
+ runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim()) ??
+ runtimes.find((r) => r.id === agent.agentCommand.trim()),
+ [runtimes, agent.agentCommand],
+ );
+ const savedRuntimeId = savedRuntime?.id ?? "custom";
+
// Reset form state only when the dialog opens or when switching to a different agent.
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — including agent fields would re-fire on every 5s poll and wipe edits
React.useEffect(() => {
@@ -209,8 +215,7 @@ export function AgentInstanceEditDialog({
setIsCustomProviderEditing(false);
setEnvVars(agent.envVars);
setAutoRestartOnConfigChange(agent.autoRestartOnConfigChange);
- setEffortLevel(null);
- effortTouched.current = false;
+ setEffortLevel(undefined);
setSetterError(null);
setRespondTo(agent.respondTo);
setRespondToAllowlist(agent.respondToAllowlist);
@@ -219,10 +224,7 @@ export function AgentInstanceEditDialog({
setIsAvatarUploadPending(false);
setIsAddHarnessOpen(false);
runtimeTouched.current = false;
- const matched =
- runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim()) ??
- runtimes.find((r) => r.id === agent.agentCommand.trim());
- setSelectedRuntimeId(matched ? matched.id : "custom");
+ setSelectedRuntimeId(savedRuntimeId);
updateMutation.reset();
}
}, [open, agent.pubkey]);
@@ -232,13 +234,10 @@ export function AgentInstanceEditDialog({
if (!open || runtimeTouched.current || runtimes.length === 0) {
return;
}
- const matched =
- runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim()) ??
- runtimes.find((r) => r.id === agent.agentCommand.trim());
- if (matched) {
- setSelectedRuntimeId(matched.id);
+ if (savedRuntime) {
+ setSelectedRuntimeId(savedRuntime.id);
}
- }, [open, runtimes, agent.agentCommand]);
+ }, [open, runtimes, savedRuntime]);
// Build the sorted runtime catalog for the dropdown.
const sortedRuntimes = React.useMemo(
@@ -434,6 +433,7 @@ export function AgentInstanceEditDialog({
discoveredModelOptions,
modelDiscoveryLoading,
modelDiscoveryStatus,
+ agentDefaultModel,
} = usePersonaModelDiscovery({
envVars: envVarsForDiscovery,
isCustomProviderEditing,
@@ -526,8 +526,7 @@ export function AgentInstanceEditDialog({
runtimeTouched.current = true;
const resolvedRuntimeId = nextRuntimeId || "custom";
setSelectedRuntimeId(resolvedRuntimeId);
- effortTouched.current = false;
- setEffortLevel(null);
+ setEffortLevel(undefined);
const isCustomCommand = resolvedRuntimeId === "custom";
// Only pin the harness when the selection can actually supply a command:
@@ -637,6 +636,16 @@ export function AgentInstanceEditDialog({
!isSaving &&
!isAvatarUploadPending;
+ // Harness pin resolution — see resolveAgentCommandUpdate for the full
+ // sentinel/pin/no-op contract. "" is the pin→inherit transition.
+ const agentCommandUpdate = resolveAgentCommandUpdate({
+ inheritHarness,
+ agentCommand,
+ originalAgentCommand: agent.agentCommand,
+ agentCommandOverride: agent.agentCommandOverride ?? null,
+ });
+ const inheritTransition = agentCommandUpdate === "";
+
async function handleSubmit() {
setIsSaving(true);
setSetterError(null);
@@ -651,16 +660,6 @@ export function AgentInstanceEditDialog({
// requires one) and a deliberate local model still wins.
const normalizedModel = inheritedSubmission.model;
- // Harness pin resolution — see resolveAgentCommandUpdate for the full
- // sentinel/pin/no-op contract, including the inherit→pin transition where
- // the prefilled command equals the original but must still be pinned.
- const agentCommandUpdate = resolveAgentCommandUpdate({
- inheritHarness,
- agentCommand,
- originalAgentCommand: agent.agentCommand,
- 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
@@ -748,17 +747,14 @@ export function AgentInstanceEditDialog({
: undefined,
};
- // Resolve effort before the update so access-change restarts can
- // snapshot and launch the NEW effort value atomically.
+ // Effort rides the locked update so restarts launch the new value.
const effortSubmission = resolveEffortSubmission({
- effortLevel,
- originalEffortLevel:
- configSurfaceQuery.data?.normalized.thinkingEffort?.value ?? null,
- inheritTransition: agentCommandUpdate === "",
+ effortLevel: effortLevel ?? null,
+ originalEffortLevel: storedEffort,
+ inheritTransition,
+ choices: effortOptions,
});
- // Include effort in the locked update when touched (tri-state: absent =
- // don't touch; null = clear; string = set). Only when effortSubmission.persist.
- if (effortTouched.current && effortSubmission.persist) {
+ if (effortTouched && effortSubmission.persist) {
input.effortLevel = effortSubmission.level;
}
@@ -777,11 +773,7 @@ export function AgentInstanceEditDialog({
autoRestartOnConfigChange,
);
}
- // Effort disk write happened inside the locked update. Only need to
- // invalidate the cache here (when effortTouched && effortSubmission.persist).
- // If effort was not included (!effortSubmission.persist), nothing to do.
- if (effortTouched.current && effortSubmission.persist) {
- // Disk write already done; invalidate so the panel tier reflects it.
+ if (effortTouched && effortSubmission.persist) {
await queryClient.invalidateQueries({
queryKey: agentConfigSurfaceQueryKey(agent.pubkey),
});
@@ -859,6 +851,20 @@ export function AgentInstanceEditDialog({
loadingValue: MODEL_DISCOVERY_LOADING_VALUE,
options: effectiveModelOptions,
});
+ const storedEffort = ownEffortLevel(normalizedConfig?.thinkingEffort);
+ const effortOptions = effortChoices({
+ runtimeId: selectedRuntime?.id,
+ models: [
+ linkedPersona ? linkedPersona.model : inheritedSubmission.model, // Save never sends a linked model
+ globalConfig.model, // build/provider fallbacks never reach Claude
+ agentDefaultModel,
+ ],
+ sessionApplies: !runtimeTouched.current,
+ session: configSurfaceQuery.data,
+ });
+ // Show a pick only while Save would keep it.
+ const effortPickKept =
+ effortTouched && isSavableEffort(effortLevel, effortOptions);
const modelStatusMessage = resolveModelFieldStatusMessage({
discoveredModelOptions,
loading: modelDiscoveryLoading,
@@ -1106,21 +1112,14 @@ export function AgentInstanceEditDialog({
/>
{
- effortTouched.current = true;
- setEffortLevel(level);
- }}
+ // A stored level survives a runtime switch; keep it clearable.
+ storedEffort={inheritTransition ? null : storedEffort}
+ value={effortPickKept ? effortLevel : storedEffort}
+ onChange={setEffortLevel}
/>
void;
}) {
- const { visible, options, selectValue } = effortPickerState({
- backend: agent.backend,
- effortConfigId: config?.effortConfigId,
- effortOptions: config?.effortOptions,
+ const { visible, options, selectValue, note } = effortPickerState({
+ backend,
+ effortOptions: choices,
currentEffort: value,
+ storedEffort,
});
if (!visible) {
@@ -68,6 +73,11 @@ export function EffortPickerField({
value={selectValue}
/>
+ {note === "unknownModel"
+ ? `Support for ${selectValue} isn't known yet. `
+ : note === "unlisted"
+ ? `This model may not support ${selectValue}. `
+ : null}
Applied at the next session start.
diff --git a/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx b/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx
index 4b04de638d..2825467734 100644
--- a/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx
+++ b/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx
@@ -50,8 +50,14 @@ export function RequestedAgentCreateDialogs() {
setTargetChannel(null);
}
}}
- onSubmitDefinition={(input, intent, backendIntent) =>
- personas.handleSubmit(input, intent, backendIntent, targetChannel)
+ onSubmitDefinition={(input, intent, backendIntent, effortLevel) =>
+ personas.handleSubmit(
+ input,
+ intent,
+ backendIntent,
+ effortLevel,
+ targetChannel,
+ )
}
runtimes={personas.acpRuntimesQuery.data ?? []}
runtimeCatalogStatus={
diff --git a/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs b/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs
index 26d845045c..50be6a3c2f 100644
--- a/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs
+++ b/desktop/src/features/agents/ui/agentInstanceEditCancelSafety.test.mjs
@@ -273,7 +273,7 @@ function installEffortIpc({ deferUpdate = false, failUpdate = false } = {}) {
};
}
-function renderDialog(onOpenChange) {
+function renderDialog(onOpenChange, agentOverrides = {}) {
const client = new QueryClient({
defaultOptions: {
mutations: { gcTime: 0 },
@@ -289,7 +289,7 @@ function renderDialog(onOpenChange) {
QueryClientProvider,
{ client },
createElement(AgentInstanceEditDialog, {
- agent: { ...toCamelAgent(rawAgent()) },
+ agent: { ...toCamelAgent(rawAgent(agentOverrides)) },
open: true,
onOpenChange,
onUpdated: () => {},
@@ -498,8 +498,8 @@ test("inherit toggle then Save dispatches the agentCommand:'' inherit sentinel",
// Opens the effort dropdown (Radix DropdownMenu trigger) and selects the option
// whose visible label matches `label`. Mirrors a real user pick — the seam the
// pure resolveEffortSubmission unit tests never touch.
-async function selectEffort(label) {
- const trigger = dom.window.document.getElementById("edit-agent-effort");
+async function selectEffort(label, id = "edit-agent-effort") {
+ const trigger = dom.window.document.getElementById(id);
assert.ok(
trigger,
"effort picker trigger must render for a local + effort-capable agent",
@@ -683,6 +683,40 @@ test("pin→inherit Save with a picked effort does not write effortLevel", async
);
});
+test("pin→inherit toggle hides the picker, stored level included", async () => {
+ // Save clears the agent's effort on this transition and drops any pick, so
+ // neither the session's levels nor a stored "max" may stay on offer.
+ installEffortIpc();
+ ipcHandlers.set("get_agent_config_surface", () =>
+ Promise.resolve({
+ ...effortConfigSurface(),
+ normalized: {
+ ...effortConfigSurface().normalized,
+ thinkingEffort: {
+ value: "max",
+ origin: "buzzExplicit",
+ writeVia: {
+ type: "respawnWithEnvVar",
+ envKey: "GOOSE_THINKING_EFFORT",
+ },
+ overriddenValue: null,
+ overriddenOrigin: null,
+ isRequired: false,
+ },
+ },
+ }),
+ );
+ await act(async () => {
+ renderDialog(() => {});
+ });
+ const effortLabel = () =>
+ dom.window.document.getElementById("edit-agent-effort")?.textContent;
+ assert.equal(effortLabel(), "max");
+
+ await expandAdvancedAndToggleInherit();
+ assert.equal(effortLabel(), undefined, "no effort control may stay on offer");
+});
+
// ── Composite isSaving gate covers the FULL Save transaction (Carl r9 P1) ────
test("Cancel and Save are disabled while the locked update is in flight", async () => {
@@ -871,15 +905,12 @@ test("runtime switch clears touched effort — no effortLevel dispatched after s
// switched → Save → stale effort value committed to the new runtime's store,
// which rejects vocab it never advertised.
//
- // The fixture uses a non-null pinned originalEffortLevel ("low") so the
- // test is sensitive to the load-bearing guard: `effortTouched.current = false`
- // in handleRuntimeDropdownChange. Without it, resolveEffortSubmission sees
- // effortLevel=null ≠ originalEffortLevel="low" and emits an unintended clear
- // write. The secondary guard (setEffortLevel(null)) alone does not suppress.
+ // The fixture stores "low" on the agent so the test is sensitive to the
+ // load-bearing guard: `setEffortLevel(undefined)` in
+ // handleRuntimeDropdownChange. Without it, the "high" pick survives the
+ // switch and Save writes it to the new runtime.
installEffortIpc();
const set = (cmd, handler) => ipcHandlers.set(cmd, handler);
- // Override the config surface with a non-null pinned effort so the original
- // value is "low" — the critical case for the effortTouched guard.
set("get_agent_config_surface", () =>
Promise.resolve({
...effortConfigSurface(),
@@ -887,8 +918,11 @@ test("runtime switch clears touched effort — no effortLevel dispatched after s
...effortConfigSurface().normalized,
thinkingEffort: {
value: "low",
- origin: "agentRecord",
- writeVia: "standalone",
+ origin: "buzzExplicit",
+ writeVia: {
+ type: "respawnWithEnvVar",
+ envKey: "GOOSE_THINKING_EFFORT",
+ },
overriddenValue: null,
overriddenOrigin: null,
isRequired: false,
@@ -928,12 +962,11 @@ test("runtime switch clears touched effort — no effortLevel dispatched after s
fireEvent.click(claudeItem);
});
- // The picker must disappear once runtimeTouched is set — its config surface
- // is only valid for the running session, not the prospective runtime.
+ // The switch discards the pick; the stored level stays shown and clearable.
assert.equal(
- dom.window.document.getElementById("edit-agent-effort"),
- null,
- "effort picker must be hidden after a runtime switch — its options are unknown for the prospective runtime",
+ dom.window.document.getElementById("edit-agent-effort")?.textContent,
+ "low",
+ "a runtime switch must show the stored level, not the discarded pick",
);
// Save — the runtime changed, so effortTouched must have been cleared.
@@ -1076,3 +1109,131 @@ test("auto-restart and inherit checkboxes are disabled while the locked update w
await new Promise((resolve) => setTimeout(resolve, 5));
});
});
+
+const CLAUDE_CATALOG = {
+ agentName: "claude",
+ models: [
+ { id: "opus[1m]", name: "Opus" },
+ { id: "haiku", name: "Haiku" },
+ ],
+ agentDefaultModel: "opus[1m]",
+ supportsSwitching: true,
+};
+const CLAUDE_INSTANCE = {
+ runtime: "claude",
+ agent_command: "claude",
+ agent_command_override: null,
+ model: "haiku",
+};
+
+test("linked agent: effort follows the definition model, not the unsaved field", async () => {
+ installIpc();
+ ipcHandlers.set("list_personas", () =>
+ Promise.resolve([rawPersona({ model: "haiku" })]),
+ );
+ ipcHandlers.set("discover_agent_models", () =>
+ Promise.resolve(CLAUDE_CATALOG),
+ );
+ await act(async () => {
+ renderDialog(() => {}, CLAUDE_INSTANCE);
+ });
+ await selectEffort("Opus", "edit-agent-model");
+ assert.equal(
+ dom.window.document.getElementById("edit-agent-effort"),
+ null,
+ "Save never sends model for a linked agent, so Haiku still runs",
+ );
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
+ });
+ const update = ipcCalls.find((call) => call.cmd === "update_managed_agent");
+ assert.equal(update.args.input.model, undefined);
+ assert.equal(update.args.input.effortLevel, undefined);
+});
+
+test("untouched Claude form ignores stored session levels it cannot attribute", async () => {
+ installIpc();
+ ipcHandlers.set("get_agent_config_surface", () =>
+ Promise.resolve({ ...effortConfigSurface(), runtimeId: "claude" }),
+ );
+ ipcHandlers.set("discover_agent_models", () =>
+ Promise.resolve(CLAUDE_CATALOG),
+ );
+ await act(async () => {
+ renderDialog(() => {}, { ...CLAUDE_INSTANCE, persona_id: null });
+ });
+ assert.equal(
+ dom.window.document.getElementById("edit-agent-effort"),
+ null,
+ "configured Haiku must not inherit levels from a session of unknown model",
+ );
+});
+
+test("an inherited global effort shows no picker before the first session", async () => {
+ // The surface reports the effective value from any tier; only the agent's
+ // own level is stored. Goose offers no levels until a session reports them.
+ installIpc();
+ ipcHandlers.set("get_agent_config_surface", () =>
+ Promise.resolve({
+ ...configSurface(),
+ isPreSpawn: true,
+ normalized: {
+ ...configSurface().normalized,
+ thinkingEffort: {
+ value: "high",
+ origin: "globalDefault",
+ writeVia: {
+ type: "respawnWithEnvVar",
+ envKey: "GOOSE_THINKING_EFFORT",
+ },
+ overriddenValue: null,
+ overriddenOrigin: null,
+ isRequired: false,
+ },
+ },
+ }),
+ );
+ await act(async () => {
+ renderDialog(() => {});
+ });
+ // A boolean, not the element: inspecting a JSDOM node for a diff hangs.
+ assert.ok(
+ dom.window.document.getElementById("edit-agent-effort") === null,
+ "a global default is not this agent's stored level to show or clear",
+ );
+});
+
+test("a registered harness whose id is custom is selected by its command", async () => {
+ // The catalog effect re-derives the selection whenever the catalog changes;
+ // "custom" is also the no-match fallback, so a match on it must not be
+ // mistaken for no match. The harness first appears under another id, so the
+ // selection only follows it if the effect accepts the "custom" match.
+ installIpc();
+ const command = "/opt/my-harness";
+ let catalog = [rawRuntime("claude"), rawRuntime("my-harness", { command })];
+ ipcHandlers.set("discover_acp_providers", () => Promise.resolve(catalog));
+ await act(async () => {
+ renderDialog(() => {}, {
+ persona_id: null,
+ agent_command: command,
+ agent_command_override: null,
+ });
+ });
+ const runtimeLabel = () =>
+ dom.window.document.getElementById("edit-agent-runtime")?.textContent;
+ assert.equal(runtimeLabel(), "my-harness");
+
+ catalog = [
+ rawRuntime("claude"),
+ rawRuntime("custom", { command, label: "My Harness" }),
+ ];
+ await act(async () => {
+ await clients.at(-1).invalidateQueries({ queryKey: ["acp-runtimes"] });
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ });
+ assert.equal(
+ runtimeLabel(),
+ "My Harness",
+ "the saved command matches the harness registered under the id custom",
+ );
+});
diff --git a/desktop/src/features/agents/ui/agentInstanceEditPinning.test.mjs b/desktop/src/features/agents/ui/agentInstanceEditPinning.test.mjs
index 878b326ac2..1293bfe584 100644
--- a/desktop/src/features/agents/ui/agentInstanceEditPinning.test.mjs
+++ b/desktop/src/features/agents/ui/agentInstanceEditPinning.test.mjs
@@ -361,6 +361,7 @@ test("inheritTransition_suppressesEffortWrite_evenWhenUserPickedAValue", () => {
effortLevel: "high", // the deferred selection that used to race the save
originalEffortLevel: null,
inheritTransition: agentCommandUpdate === "",
+ choices: [{ value: "high" }],
});
assert.equal(
submission.persist,
@@ -376,6 +377,7 @@ test("effortWrite_persistsRealChange_onNonInheritSave", () => {
effortLevel: "high",
originalEffortLevel: null,
inheritTransition: false,
+ choices: [{ value: "high" }],
});
assert.deepEqual(submission, { persist: true, level: "high" });
});
@@ -387,6 +389,7 @@ test("effortWrite_noOp_whenSelectionUnchanged", () => {
effortLevel: "high",
originalEffortLevel: "high",
inheritTransition: false,
+ choices: [{ value: "high" }],
});
assert.equal(submission.persist, false);
});
@@ -398,6 +401,18 @@ test("effortWrite_clearToAdapterDefault_persistsNull", () => {
effortLevel: null,
originalEffortLevel: "high",
inheritTransition: false,
+ choices: [{ value: "high" }],
});
assert.deepEqual(submission, { persist: true, level: null });
});
+
+test("effortWrite_dropped_whenSavedModelDoesNotOfferIt", () => {
+ // Stored Opus levels, user picked "max", then switched to Haiku: no choices.
+ const submission = resolveEffortSubmission({
+ effortLevel: "max",
+ originalEffortLevel: null,
+ inheritTransition: false,
+ choices: undefined,
+ });
+ assert.equal(submission.persist, false);
+});
diff --git a/desktop/src/features/agents/ui/effortPicker.test.mjs b/desktop/src/features/agents/ui/effortPicker.test.mjs
index 28c22ec9d9..0bd2f4f46b 100644
--- a/desktop/src/features/agents/ui/effortPicker.test.mjs
+++ b/desktop/src/features/agents/ui/effortPicker.test.mjs
@@ -3,8 +3,11 @@ import test from "node:test";
import {
EFFORT_DEFAULT_DROPDOWN_VALUE,
+ EFFORT_LEVELS_UNKNOWN,
effortPickerState,
+ effortChoices,
effortSelectionToPersistedValue,
+ isSavableEffort,
} from "./effortPicker.ts";
const localBackend = { type: "local" };
@@ -14,30 +17,27 @@ const options = [
{ value: "high", displayName: "High" },
];
-test("effort picker renders for a local backend with a discovered configId", () => {
+test("effort picker renders for a local backend with known levels", () => {
const state = effortPickerState({
backend: localBackend,
- effortConfigId: "thought_level",
effortOptions: options,
currentEffort: null,
});
assert.equal(state.visible, true);
});
-test("effort picker is hidden for a provider backend even when a configId exists", () => {
+test("effort picker is hidden for a provider backend even with known levels", () => {
const state = effortPickerState({
backend: providerBackend,
- effortConfigId: "thought_level",
effortOptions: options,
currentEffort: "high",
});
assert.equal(state.visible, false);
});
-test("effort picker is hidden for a local backend without a discovered configId", () => {
+test("effort picker is hidden for a local backend whose model offers no levels", () => {
const state = effortPickerState({
backend: localBackend,
- effortConfigId: undefined,
effortOptions: undefined,
currentEffort: null,
});
@@ -47,7 +47,6 @@ test("effort picker is hidden for a local backend without a discovered configId"
test("options lead with the adapter-default sentinel then adapter values", () => {
const state = effortPickerState({
backend: localBackend,
- effortConfigId: "thought_level",
effortOptions: options,
currentEffort: null,
});
@@ -61,7 +60,6 @@ test("options lead with the adapter-default sentinel then adapter values", () =>
test("option label falls back to the raw value when displayName is absent", () => {
const state = effortPickerState({
backend: localBackend,
- effortConfigId: "thought_level",
effortOptions: [{ value: "medium" }],
currentEffort: null,
});
@@ -71,7 +69,6 @@ test("option label falls back to the raw value when displayName is absent", () =
test("current effort preselects the matching option", () => {
const state = effortPickerState({
backend: localBackend,
- effortConfigId: "thought_level",
effortOptions: options,
currentEffort: "high",
});
@@ -81,7 +78,6 @@ test("current effort preselects the matching option", () => {
test("an unknown current effort falls back to the adapter-default sentinel", () => {
const state = effortPickerState({
backend: localBackend,
- effortConfigId: "thought_level",
effortOptions: options,
currentEffort: "extreme",
});
@@ -91,7 +87,6 @@ test("an unknown current effort falls back to the adapter-default sentinel", ()
test("a null current effort selects the adapter-default sentinel", () => {
const state = effortPickerState({
backend: localBackend,
- effortConfigId: "thought_level",
effortOptions: options,
currentEffort: null,
});
@@ -108,3 +103,189 @@ test("the sentinel selection persists as null (clear to adapter default)", () =>
test("a concrete selection persists as its explicit effort level", () => {
assert.equal(effortSelectionToPersistedValue("high"), "high");
});
+
+const values = (choices) => choices?.map((choice) => choice.value);
+const storedOpusSession = {
+ effortConfigId: "thought_level",
+ effortOptions: [{ value: "low" }, { value: "max" }],
+};
+
+test("effortChoices_blankModel_usesAdapterDefaultOpus1mAlias", () => {
+ const choices = effortChoices({
+ runtimeId: "claude",
+ models: ["", null, "opus[1m]"],
+ sessionApplies: false,
+ });
+ assert.deepEqual(values(choices), ["low", "medium", "high"]);
+});
+
+test("effortChoices_haikuGlobalOverride_beatsAdapterDefault", () => {
+ const choices = effortChoices({
+ runtimeId: "claude",
+ models: ["", "claude-haiku-4-5", "opus[1m]"],
+ sessionApplies: false,
+ });
+ assert.equal(choices, undefined);
+});
+
+test("effortChoices_explicitModel_beatsGlobalOverride", () => {
+ const choices = effortChoices({
+ runtimeId: "claude",
+ models: ["claude-opus-4-8", "claude-haiku-4-5", null],
+ sessionApplies: false,
+ });
+ assert.deepEqual(values(choices), ["low", "medium", "high", "xhigh", "max"]);
+});
+
+test("effortChoices_claudeStoredSession_neverTrusted", () => {
+ // The stored surface does not say which model the session ran.
+ const choices = effortChoices({
+ runtimeId: "claude",
+ models: ["claude-haiku-4-5"],
+ sessionApplies: true,
+ session: storedOpusSession,
+ });
+ assert.equal(choices, undefined);
+});
+
+test("effortChoices_codexStoredSession_winsWhileRuntimeUnchanged", () => {
+ const choices = effortChoices({
+ runtimeId: "codex",
+ models: ["gpt-5.5"],
+ sessionApplies: true,
+ session: storedOpusSession,
+ });
+ assert.deepEqual(values(choices), ["low", "max"]);
+});
+
+test("effortChoices_nonClaudeRuntime_keepsNativeOnlyBehavior", () => {
+ const choices = effortChoices({
+ runtimeId: "codex",
+ models: ["opus[1m]"],
+ sessionApplies: false,
+ });
+ assert.equal(choices, undefined);
+});
+
+test("effortChoices_discoveryPendingOrFailed_reportsUnknownModel", () => {
+ // The hook returns agentDefaultModel: null until its keyed response lands.
+ const choices = effortChoices({
+ runtimeId: "claude",
+ models: ["", null, null],
+ sessionApplies: false,
+ });
+ assert.equal(choices, EFFORT_LEVELS_UNKNOWN);
+});
+
+for (const alias of ["default", "opusplan", "default[1m]", "OpusPlan[1m]"]) {
+ test(`effortChoices_claudeAlias_${alias}_offersAliasLevels`, () => {
+ const choices = effortChoices({
+ runtimeId: "claude",
+ models: [alias],
+ sessionApplies: false,
+ });
+ assert.deepEqual(values(choices), ["low", "medium", "high"]);
+ });
+}
+
+test("effortChoices_unrecognizedClaudeModel_offersNone", () => {
+ // The manifest's unknown fallback is also what Haiku resolves to, so an
+ // unrecognized id cannot be told apart from a model without levels.
+ const choices = effortChoices({
+ runtimeId: "claude",
+ models: ["claude-future-9"],
+ sessionApplies: false,
+ });
+ assert.equal(choices, undefined);
+});
+
+test("isSavableEffort_rejectsLevelTheModelDoesNotOffer", () => {
+ assert.equal(isSavableEffort("high", undefined), false);
+ assert.equal(isSavableEffort("max", [{ value: "high" }]), false);
+ assert.equal(isSavableEffort("high", [{ value: "high" }]), true);
+ assert.equal(isSavableEffort(null, undefined), true);
+});
+
+test("isSavableEffort_unknownModel_keepsThePick", () => {
+ assert.equal(isSavableEffort("high", EFFORT_LEVELS_UNKNOWN), true);
+});
+
+test("effortPickerState_unknownModelNothingStored_hidesThePicker", () => {
+ const state = effortPickerState({
+ backend: localBackend,
+ effortOptions: EFFORT_LEVELS_UNKNOWN,
+ currentEffort: "high",
+ });
+ assert.equal(state.visible, false);
+});
+
+test("effortPickerState_unknownModelWithStoredLevel_showsItAsUnknown", () => {
+ const state = effortPickerState({
+ backend: localBackend,
+ effortOptions: EFFORT_LEVELS_UNKNOWN,
+ currentEffort: "max",
+ storedEffort: "max",
+ });
+ assert.equal(state.visible, true);
+ assert.equal(state.note, "unknownModel");
+ assert.deepEqual(state.options, [
+ { label: "Adapter default", value: EFFORT_DEFAULT_DROPDOWN_VALUE },
+ { label: "max", value: "max" },
+ ]);
+});
+
+test("effortPickerState_unknownModelWithStoredLevel_showsThePickSaveKeeps", () => {
+ // isSavableEffort keeps any pick while the model is unknown.
+ const state = effortPickerState({
+ backend: localBackend,
+ effortOptions: EFFORT_LEVELS_UNKNOWN,
+ currentEffort: "high",
+ storedEffort: "max",
+ });
+ assert.equal(state.selectValue, "high");
+ assert.equal(state.note, "unknownModel");
+ assert.deepEqual(
+ state.options.map((option) => option.value),
+ [EFFORT_DEFAULT_DROPDOWN_VALUE, "max", "high"],
+ );
+});
+
+test("effortPickerState_storedLevelTheModelDoesNotList_staysSelectable", () => {
+ const state = effortPickerState({
+ backend: localBackend,
+ effortOptions: undefined,
+ currentEffort: "max",
+ storedEffort: "max",
+ });
+ assert.equal(state.visible, true);
+ assert.equal(state.note, "unlisted");
+ assert.equal(state.selectValue, "max");
+ assert.deepEqual(state.options, [
+ { label: "Adapter default", value: EFFORT_DEFAULT_DROPDOWN_VALUE },
+ { label: "max", value: "max" },
+ ]);
+});
+
+test("effortPickerState_clearedUnlistedStoredLevel_staysOfferedButUnselected", () => {
+ const state = effortPickerState({
+ backend: localBackend,
+ effortOptions: options,
+ currentEffort: null,
+ storedEffort: "max",
+ });
+ assert.equal(state.visible, true);
+ assert.equal(state.note, null);
+ assert.equal(state.selectValue, EFFORT_DEFAULT_DROPDOWN_VALUE);
+ assert.equal(state.options.at(-1).value, "max");
+});
+
+test("effortPickerState_listedSelection_hasNoNote", () => {
+ const state = effortPickerState({
+ backend: localBackend,
+ effortOptions: options,
+ currentEffort: "high",
+ storedEffort: "max",
+ });
+ assert.equal(state.selectValue, "high");
+ assert.equal(state.note, null);
+});
diff --git a/desktop/src/features/agents/ui/effortPicker.ts b/desktop/src/features/agents/ui/effortPicker.ts
index 9459b1aef6..365cb8b15b 100644
--- a/desktop/src/features/agents/ui/effortPicker.ts
+++ b/desktop/src/features/agents/ui/effortPicker.ts
@@ -1,8 +1,11 @@
import type {
AcpConfigOptionValue,
ManagedAgentBackend,
+ NormalizedField,
+ RuntimeConfigSurface,
} from "@/shared/api/types";
import type { PersonaDropdownOption } from "./agentConfigOptions";
+import { resolveModelCapabilities } from "./modelCapabilities";
/**
* Sentinel dropdown value for "no explicit effort" — reverts the agent to the
@@ -11,54 +14,84 @@ import type { PersonaDropdownOption } from "./agentConfigOptions";
export const EFFORT_DEFAULT_DROPDOWN_VALUE = "__effort_default__";
/**
- * Pure gating + option compute for the effort write control in the edit dialog.
+ * Pure gating + option compute for the effort write control.
*
* The picker is a LOCAL-only, Save-gated write control: the dialog embeds the
* selection in the locked `update_managed_agent` payload (PR #4625), which the
* Rust backend rejects for non-local backends (remote effort is set at deploy
* time via `policy_env`). So the UI must not offer it for a provider backend,
- * and there's nothing to pick until the adapter has advertised a `thought_level`
- * config option (discovered from the running session — `effortConfigId` is
- * absent pre-first-session and for runtimes/models that don't support effort).
+ * and there's nothing to pick until `effortChoices` knows the model's levels.
*
- * `visible` is the single gate the dialog renders on: local backend AND a
- * discovered `effortConfigId`.
+ * `visible` is the single gate the dialog renders on: local backend AND either
+ * known choices or a stored level, which stays shown so it can be cleared.
*/
export function effortPickerState({
backend,
- effortConfigId,
effortOptions,
currentEffort,
+ storedEffort = null,
}: {
backend: ManagedAgentBackend;
- effortConfigId: string | undefined;
- effortOptions: readonly AcpConfigOptionValue[] | undefined;
+ effortOptions: EffortOptions;
currentEffort: string | null;
+ /** The saved effort; kept selectable even when the model doesn't list it. */
+ storedEffort?: string | null;
}): {
visible: boolean;
options: PersonaDropdownOption[];
selectValue: string;
+ /**
+ * Why the selected level may not apply: the model doesn't list it
+ * (`unlisted`), or the model isn't known yet (`unknownModel`).
+ */
+ note: "unlisted" | "unknownModel" | null;
} {
- const visible = backend.type === "local" && effortConfigId !== undefined;
+ const listed = Array.isArray(effortOptions) ? effortOptions : [];
+ const unknownModel = effortOptions === EFFORT_LEVELS_UNKNOWN;
+ const isListed = (value: string) =>
+ listed.some((option) => option.value === value);
+ const stored = storedEffort?.trim() ?? "";
+ const current = currentEffort?.trim() ?? "";
+ // Unlisted levels that stay selectable rather than reading as "Adapter
+ // default": a saved level, which only the user may clear, and while the
+ // model is unknown, the pick Save keeps.
+ const unlisted = [...new Set([stored, unknownModel ? current : ""])].filter(
+ (value) => value.length > 0 && !isListed(value),
+ );
+ const visible =
+ backend.type === "local" &&
+ (Array.isArray(effortOptions) || stored.length > 0);
const options: PersonaDropdownOption[] = [
{ label: "Adapter default", value: EFFORT_DEFAULT_DROPDOWN_VALUE },
- ...(effortOptions ?? []).map((option) => ({
+ ...listed.map((option) => ({
label: option.displayName ?? option.value,
value: option.value,
})),
+ ...unlisted.map((value) => ({ label: value, value })),
];
- // Preselect the currently-configured effort when it maps to a known option;
- // otherwise fall back to the adapter-default sentinel (also the null case).
- const trimmed = currentEffort?.trim() ?? "";
- const selectValue =
- trimmed.length > 0 &&
- (effortOptions ?? []).some((option) => option.value === trimmed)
- ? trimmed
- : EFFORT_DEFAULT_DROPDOWN_VALUE;
+ const selectedUnlisted = unlisted.includes(current);
+ return {
+ visible,
+ options,
+ selectValue:
+ selectedUnlisted || (current.length > 0 && isListed(current))
+ ? current
+ : EFFORT_DEFAULT_DROPDOWN_VALUE,
+ note: !selectedUnlisted ? null : unknownModel ? "unknownModel" : "unlisted",
+ };
+}
- return { visible, options, selectValue };
+/**
+ * The agent's own saved effort level from its config surface. A session,
+ * definition, global, or config-file value is not the agent's to show as
+ * stored or to clear.
+ */
+export function ownEffortLevel(
+ field: NormalizedField | null | undefined,
+): string | null {
+ return field?.origin === "buzzExplicit" ? field.value : null;
}
/**
@@ -69,3 +102,91 @@ export function effortPickerState({
export function effortSelectionToPersistedValue(value: string): string | null {
return value === EFFORT_DEFAULT_DROPDOWN_VALUE ? null : value;
}
+
+/**
+ * Claude will run some model, but none is known yet (discovery pending or
+ * failed). Distinct from `undefined`, which means the model has no levels.
+ */
+export const EFFORT_LEVELS_UNKNOWN = "unknown";
+
+/**
+ * Offered effort levels; `undefined` = the model offers none,
+ * `EFFORT_LEVELS_UNKNOWN` = the model isn't known yet. Both hide the picker
+ * unless a level is stored, which stays visible and clearable.
+ */
+export type EffortOptions =
+ | readonly AcpConfigOptionValue[]
+ | typeof EFFORT_LEVELS_UNKNOWN
+ | undefined;
+
+/** Model ids in precedence order: explicit/persona, global, adapter default. */
+export type EffortModels = readonly (string | null | undefined)[];
+
+/** Claude Code's model aliases, which the manifest does not resolve. */
+const CLAUDE_ALIASES = new Set(["default", "opus", "opusplan", "sonnet"]);
+const CLAUDE_ALIAS_EFFORTS = ["low", "medium", "high"];
+
+/**
+ * Effort levels to offer for the model that will actually run, or `undefined`
+ * to hide the picker (`EFFORT_LEVELS_UNKNOWN` while Claude's model is not yet
+ * known). Claude levels always come from the capability manifest, except for
+ * Claude Code's aliases. A model the manifest doesn't know offers none.
+ * Other runtimes keep native-only behavior: the running session's own list
+ * while the runtime is unchanged (`sessionApplies`). The first
+ * non-blank of `models` wins. A blank id must never reach the manifest: its blank
+ * fallback is adaptive and would invent levels for an unknown default.
+ */
+export function effortChoices({
+ runtimeId,
+ models,
+ sessionApplies,
+ session,
+}: {
+ runtimeId: string | undefined;
+ models: EffortModels;
+ sessionApplies: boolean;
+ session?: RuntimeConfigSurface;
+}): EffortOptions {
+ // Claude's stored surface does not record which model its session ran, so
+ // its levels cannot be trusted for the saved model; the manifest decides.
+ if (
+ runtimeId !== "claude" &&
+ sessionApplies &&
+ session?.effortConfigId !== undefined
+ ) {
+ return session.effortOptions ?? [];
+ }
+ if (runtimeId !== "claude") {
+ return undefined;
+ }
+ const id = models.map((model) => model?.trim()).find(Boolean);
+ if (!id) {
+ return EFFORT_LEVELS_UNKNOWN;
+ }
+ const alias = id.toLowerCase().replace(/\[1m\]$/, "");
+ const { thinkingMode, supportedEfforts } = resolveModelCapabilities(
+ "anthropic",
+ id,
+ );
+ const levels = CLAUDE_ALIASES.has(alias)
+ ? CLAUDE_ALIAS_EFFORTS
+ : thinkingMode === "adaptive" || thinkingMode === "manual-budget"
+ ? supportedEfforts
+ : [];
+ return levels.length > 0 ? levels.map((value) => ({ value })) : undefined;
+}
+
+/**
+ * Whether a pending effort selection may be saved for the given choices. An
+ * unknown model keeps the pick: the harness tolerates a level it rejects.
+ */
+export function isSavableEffort(
+ level: string | null,
+ choices: EffortOptions,
+): boolean {
+ return (
+ level === null ||
+ choices === EFFORT_LEVELS_UNKNOWN ||
+ (choices ?? []).some((choice) => choice.value === level)
+ );
+}
diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.ts b/desktop/src/features/agents/ui/personaRuntimeModel.ts
index 69157be6c7..9ab66539f7 100644
--- a/desktop/src/features/agents/ui/personaRuntimeModel.ts
+++ b/desktop/src/features/agents/ui/personaRuntimeModel.ts
@@ -1,3 +1,5 @@
+import { type EffortOptions, isSavableEffort } from "./effortPicker";
+
/** Runtime provider-capability tri-state used by the submit path. */
export type ProviderRuntimeCapability = "capable" | "locked" | "unknown";
@@ -105,16 +107,20 @@ export function resolveAgentCommandUpdate(input: {
* effort write entirely, regardless of the picked value.
*
* Otherwise persist only a real change: an unchanged selection is a no-op, so
- * a name-only edit never rewrites the effort column.
+ * a name-only edit never rewrites the effort column. A level the model being
+ * saved doesn't offer (`choices`, e.g. after switching to Haiku) is dropped;
+ * while that model is still unknown the pick is kept.
*/
export function resolveEffortSubmission(input: {
effortLevel: string | null;
originalEffortLevel: string | null;
inheritTransition: boolean;
+ choices: EffortOptions;
}): { persist: boolean; level: string | null } {
if (
input.inheritTransition ||
- input.effortLevel === input.originalEffortLevel
+ input.effortLevel === input.originalEffortLevel ||
+ !isSavableEffort(input.effortLevel, input.choices)
) {
return { persist: false, level: null };
}
diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts
index 617a122686..58b351a201 100644
--- a/desktop/src/features/agents/ui/usePersonaActions.ts
+++ b/desktop/src/features/agents/ui/usePersonaActions.ts
@@ -176,6 +176,7 @@ export function usePersonaActions() {
input: CreatePersonaInput | UpdatePersonaInput,
intent?: AgentCreateIntent,
backendIntent?: BackendIntent | null,
+ effortLevel?: string | null,
targetChannel?: Pick | null,
options?: { publishCatalogUpdates?: boolean },
): Promise {
@@ -242,6 +243,7 @@ export function usePersonaActions() {
runtime,
undefined,
startIntent ?? undefined,
+ effortLevel,
);
try {
diff --git a/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts b/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts
index 61ffb88ada..70ef24e169 100644
--- a/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts
+++ b/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts
@@ -441,5 +441,7 @@ export function usePersonaModelDiscovery({
? null
: activeModelDiscoveryStatus,
modelDiscoverySuccessfulEmpty,
+ agentDefaultModel:
+ activeModelDiscoveryData?.agentDefaultModel?.trim() || null,
};
}
diff --git a/desktop/src/features/agents/useAgentManagement.ts b/desktop/src/features/agents/useAgentManagement.ts
index 96363945c4..29bc97c292 100644
--- a/desktop/src/features/agents/useAgentManagement.ts
+++ b/desktop/src/features/agents/useAgentManagement.ts
@@ -156,6 +156,7 @@ export function useAgentManagement() {
input: CreatePersonaInput | UpdatePersonaInput,
intent: AgentCreateIntent,
backendIntent: BackendIntent | null,
+ effortLevel: string | null,
): Promise {
if (request?.action !== "create" || "id" in input) {
return false;
@@ -188,6 +189,7 @@ export function useAgentManagement() {
runtime,
undefined,
backendIntent ?? undefined,
+ effortLevel,
),
);
if (created.spawnError) throw new Error(created.spawnError);
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts
index 093f9e62ed..3824df8389 100644
--- a/desktop/src/shared/api/tauri.ts
+++ b/desktop/src/shared/api/tauri.ts
@@ -782,6 +782,7 @@ export async function createManagedAgent(input: CreateManagedAgentInput) {
spawnAfterCreate: input.spawnAfterCreate,
startOnAppLaunch: input.startOnAppLaunch,
backend: input.backend,
+ effortLevel: input.effortLevel,
respondTo: input.respondTo,
respondToAllowlist: input.respondToAllowlist,
relayMesh: input.relayMesh,
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts
index 334735d150..3460494a44 100644
--- a/desktop/src/shared/api/types.ts
+++ b/desktop/src/shared/api/types.ts
@@ -420,6 +420,8 @@ export type CreateManagedAgentInput = {
spawnAfterCreate?: boolean;
startOnAppLaunch?: boolean;
backend?: ManagedAgentBackend;
+ /** Local-only thinking effort saved on the new record before first spawn. */
+ effortLevel?: string;
/** Omitted uses the linked persona default, then `"owner-only"`. */
respondTo?: RespondToMode;
/**
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index e4627ed8d8..4e3b0f6d2b 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -120,6 +120,8 @@ export type MockManagedAgentSeed = {
personaId?: string | null;
/** Harness/runtime id pin; `null` = inherit from persona (native default). */
runtime?: string | null;
+ /** Model pinned on the instance record. */
+ model?: string | null;
status?: RawManagedAgent["status"];
channelNames?: string[];
channelIds?: string[];
@@ -679,6 +681,10 @@ type E2eConfig = {
* returning a catalog.
*/
discoverAgentModelsError?: string;
+ /** Delay (ms) before `discover_agent_models` settles. */
+ discoverAgentModelsDelayMs?: number;
+ /** Config surface returned for every agent instead of the per-runtime mocks. */
+ agentConfigSurface?: Record;
/** ACP commands returned by the discovery IPC in mock mode. */
acpCommands?: Array<{ command: string; binaryPath: string }>;
// Backend provider mocks for the create-agent "Run on" section. See
@@ -2540,7 +2546,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent {
parallelism: 1,
system_prompt: null,
avatar_url: seed.avatarUrl ?? null,
- model: null,
+ model: seed.model ?? null,
env_vars: { ...(seed.envVars ?? {}) },
status,
pid: status === "running" ? 42000 + mockManagedAgents.length : null,
@@ -14175,6 +14181,10 @@ export function maybeInstallE2eTauriMocks() {
supportsSwitching: false,
};
case "discover_agent_models": {
+ const discoverDelayMs = activeConfig?.mock?.discoverAgentModelsDelayMs;
+ if (discoverDelayMs) {
+ await new Promise((resolve) => setTimeout(resolve, discoverDelayMs));
+ }
const discoverError = activeConfig?.mock?.discoverAgentModelsError;
if (discoverError) {
throw new Error(discoverError);
@@ -14280,6 +14290,8 @@ export function maybeInstallE2eTauriMocks() {
}
case "get_agent_config_surface": {
const configArgs = payload as { pubkey: string };
+ const surfaceOverride = activeConfig?.mock?.agentConfigSurface;
+ if (surfaceOverride) return surfaceOverride;
return buildMockConfigSurface(configArgs.pubkey);
}
case "get_runtime_file_config": {
diff --git a/desktop/tests/e2e/claude-effort-picker.spec.ts b/desktop/tests/e2e/claude-effort-picker.spec.ts
new file mode 100644
index 0000000000..81d86645a5
--- /dev/null
+++ b/desktop/tests/e2e/claude-effort-picker.spec.ts
@@ -0,0 +1,668 @@
+/**
+ * Thinking-effort picker in the real Edit and Create dialogs for Claude Code.
+ *
+ * Levels never come from the stored session, which does not record the model
+ * it ran; they come from Claude model data for the model that will run
+ * (explicit, then global, then the adapter's reported default). A level the
+ * saved model does not offer is never submitted, and only the agent's own
+ * saved level shows as stored.
+ */
+import { expect, test, type Locator, type Page } from "@playwright/test";
+
+import {
+ installMockBridge,
+ type MockBridgeOptions,
+ TEST_IDENTITIES,
+} from "../helpers/bridge";
+
+const AGENT_NAME = "Effort Agent";
+
+const RUNTIMES = [
+ ["claude", "Claude Code"],
+ ["codex", "Codex"],
+].map(([id, label]) => ({
+ id,
+ label,
+ avatar_url: "",
+ availability: "available",
+ command: id,
+ binary_path: `/mock/${id}`,
+ default_args: [],
+ mcp_command: null,
+ install_hint: null,
+ install_instructions_url: null,
+ can_auto_install: false,
+ requires_external_cli: true,
+ underlying_cli_path: `/mock/${id}`,
+ node_required: false,
+ auth_status: { status: "authenticated" },
+ source: "builtin",
+}));
+
+// The Claude adapter's catalog as reported by claude-agent-acp 0.36.1.
+const CLAUDE_MODELS = {
+ supportsSwitching: true,
+ agentDefaultModel: "opus[1m]",
+ models: [
+ { id: "opus[1m]", name: "Opus" },
+ { id: "claude-sonnet-5", name: "Sonnet" },
+ { id: "haiku", name: "Haiku" },
+ ],
+};
+
+/**
+ * `origin` is the tier the reported effort resolved from; only `buzzExplicit`
+ * is the agent's own saved level.
+ */
+function surface(
+ effort?: { configId: string; options: string[] },
+ storedEffort?: string,
+ origin = "buzzExplicit",
+) {
+ return {
+ runtimeId: "claude",
+ runtimeLabel: "Claude Code",
+ isPreSpawn: !effort,
+ normalized: {
+ model: null,
+ provider: null,
+ mode: null,
+ thinkingEffort: storedEffort
+ ? {
+ value: storedEffort,
+ origin,
+ writeVia: {
+ type: "respawnWithEnvVar",
+ envKey: "BUZZ_ACP_EFFORT_LEVEL",
+ },
+ overriddenValue: null,
+ overriddenOrigin: null,
+ isRequired: false,
+ }
+ : null,
+ maxOutputTokens: null,
+ contextLimit: null,
+ systemPrompt: null,
+ },
+ advanced: [],
+ extensions: [],
+ sources: {
+ acpNative: "available",
+ acpConfigOptions: "available",
+ envVars: "notApplicable",
+ configFile: "available",
+ configFilePath: "~/.claude/settings.json",
+ mcpConfigFilePath: "~/.claude.json",
+ },
+ ...(effort && {
+ effortConfigId: effort.configId,
+ effortOptions: effort.options.map((value) => ({ value })),
+ }),
+ };
+}
+
+const AGENT = {
+ pubkey: TEST_IDENTITIES.tyler.pubkey,
+ name: AGENT_NAME,
+ runtime: "claude",
+ status: "stopped" as const,
+ channelNames: ["agents"],
+};
+
+async function install(page: Page, mock: MockBridgeOptions = {}) {
+ await installMockBridge(page, {
+ acpRuntimesCatalog: RUNTIMES,
+ globalAgentConfig: {
+ env_vars: {},
+ provider: null,
+ model: null,
+ preferred_runtime: "claude",
+ },
+ discoverAgentModels: CLAUDE_MODELS,
+ managedAgents: [AGENT],
+ agentConfigSurface: surface(),
+ ...mock,
+ });
+}
+
+async function payloadsFor(page: Page, command: string) {
+ return page.evaluate(
+ (name) =>
+ (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])
+ .filter((entry) => entry.command === name)
+ .map((entry) => entry.payload as { input: Record }),
+ command,
+ );
+}
+
+async function menuValues(page: Page, trigger: Locator) {
+ await trigger.click();
+ const rows = await page.getByRole("menuitemradio").allTextContents();
+ await page.keyboard.press("Escape");
+ return rows;
+}
+
+async function pick(page: Page, trigger: Locator, name: string) {
+ await trigger.press("Enter");
+ const option = page.getByRole("menuitemradio", { name, exact: true });
+ await expect(option).toBeVisible();
+ // Keyboard selection avoids racing the menu's open animation.
+ await option.press("Enter");
+ await expect(trigger).toHaveAttribute("aria-expanded", "false");
+}
+
+const DISCOVERY_FAILED = "Could not load live models for this provider.";
+const ADAPTER_LEVELS = ["Adapter default", "low", "medium", "high"];
+
+/** A build-baked model, which never launches with Claude. */
+function bakedModel(value: string) {
+ return { bakedBuildEnv: [{ key: "BUZZ_AGENT_MODEL", value, masked: false }] };
+}
+
+/** Make the next discovery hang (`pending`) or reject (`failed`). */
+async function stallDiscovery(page: Page, mode: "pending" | "failed") {
+ await page.evaluate((nextMode) => {
+ const mock = window.__BUZZ_E2E__?.mock;
+ if (!mock) throw new Error("mock bridge missing");
+ if (nextMode === "pending") mock.discoverAgentModelsDelayMs = 60_000;
+ else mock.discoverAgentModelsError = "discovery failed";
+ }, mode);
+}
+
+async function openEdit(page: Page) {
+ await page.goto("/");
+ await page.getByTestId("open-agents-view").click();
+ await page
+ .getByRole("button", { name: `${AGENT_NAME} agent profile`, exact: true })
+ .click();
+ await page.getByTestId("user-profile-edit-agent").click();
+ const dialog = page.getByTestId("edit-agent-dialog");
+ await expect(dialog).toBeVisible();
+ return dialog;
+}
+
+async function openCreate(page: Page) {
+ await page.goto("/");
+ await page.getByTestId("open-agents-view").click();
+ await page.getByTestId("new-agent-card").click();
+ const dialog = page.getByTestId("persona-dialog");
+ await expect(dialog).toBeVisible();
+ await dialog.getByLabel("Agent name").fill("Effort Create");
+ await dialog.getByLabel("Agent instruction").fill("Review code.");
+ await dialog.getByRole("button", { name: "Advanced", exact: true }).click();
+ return dialog;
+}
+
+async function pickCreateModel(page: Page, dialog: Locator, name: string) {
+ const customize = dialog.getByRole("tab", {
+ name: "Customize for this agent",
+ });
+ if ((await customize.getAttribute("aria-selected")) !== "true") {
+ await customize.click();
+ }
+ await dialog.locator("#persona-model").click();
+ await page.getByRole("button", { name, exact: true }).click();
+}
+
+test.describe("edit dialog", () => {
+ test("an untouched form ignores stored session levels from another model", async ({
+ page,
+ }) => {
+ // The stored surface does not record which model the session ran; Opus
+ // levels come from the model data, not the session's low/max.
+ await install(page, {
+ managedAgents: [{ ...AGENT, model: "opus[1m]" }],
+ agentConfigSurface: surface({
+ configId: "thought_level",
+ options: ["low", "max"],
+ }),
+ });
+ const dialog = await openEdit(page);
+ const effort = dialog.locator("#edit-agent-effort");
+ expect(await menuValues(page, effort)).toEqual([
+ "Adapter default",
+ "low",
+ "medium",
+ "high",
+ ]);
+ await pick(page, effort, "high");
+
+ await pick(page, dialog.locator("#edit-agent-model"), "Haiku");
+ await expect(effort).toHaveCount(0);
+
+ await dialog.getByRole("button", { name: "Save changes" }).click();
+ await expect(dialog).toHaveCount(0);
+ const updates = await payloadsFor(page, "update_managed_agent");
+ expect(updates).toHaveLength(1);
+ expect(updates[0].input).not.toHaveProperty("effortLevel");
+ });
+
+ test("blank model offers the adapter default opus[1m] levels", async ({
+ page,
+ }) => {
+ await install(page);
+ const dialog = await openEdit(page);
+ expect(
+ await menuValues(page, dialog.locator("#edit-agent-effort")),
+ ).toEqual(["Adapter default", "low", "medium", "high"]);
+ });
+
+ for (const baked of ["gpt-5.5", "claude-haiku-4-5"]) {
+ test(`a baked ${baked} build model does not decide Claude levels`, async ({
+ page,
+ }) => {
+ await install(page, bakedModel(baked));
+ const dialog = await openEdit(page);
+ expect(
+ await menuValues(page, dialog.locator("#edit-agent-effort")),
+ ).toEqual(ADAPTER_LEVELS);
+ });
+ }
+
+ for (const mode of ["pending", "failed"] as const) {
+ test(`a pick survives Save while rediscovery is ${mode}`, async ({
+ page,
+ }) => {
+ await install(page, {
+ managedAgents: [{ ...AGENT, envVars: { EFFORT_TEST: "one" } }],
+ });
+ const dialog = await openEdit(page);
+ const effort = dialog.locator("#edit-agent-effort");
+ await pick(page, effort, "high");
+
+ await stallDiscovery(page, mode);
+ await dialog
+ .getByRole("button", { name: "Advanced", exact: true })
+ .click();
+ await dialog.getByTestId("env-vars-value").first().fill("two");
+ await expect(effort).toHaveCount(0);
+ if (mode === "failed")
+ await expect(dialog).toContainText(DISCOVERY_FAILED);
+
+ await dialog.getByRole("button", { name: "Save changes" }).click();
+ await expect(dialog).toHaveCount(0);
+ const updates = await payloadsFor(page, "update_managed_agent");
+ expect(updates).toHaveLength(1);
+ expect(updates[0].input.effortLevel).toBe("high");
+ });
+ }
+
+ test("a stored level the new model doesn't list stays visible and clearable", async ({
+ page,
+ }) => {
+ await install(page, {
+ managedAgents: [{ ...AGENT, model: "claude-opus-4-8" }],
+ agentConfigSurface: surface(undefined, "max"),
+ discoverAgentModels: {
+ ...CLAUDE_MODELS,
+ models: [
+ ...CLAUDE_MODELS.models,
+ { id: "claude-opus-4-8", name: "Opus 4.8" },
+ ],
+ },
+ });
+ const dialog = await openEdit(page);
+ const effort = dialog.locator("#edit-agent-effort");
+ const model = dialog.locator("#edit-agent-model");
+ await expect(effort).toHaveText("max");
+
+ await pick(page, model, "Opus");
+ await expect(effort).toHaveText("max");
+ await expect(dialog).toContainText("This model may not support max.");
+
+ await pick(page, model, "Haiku");
+ await expect(effort).toHaveText("max");
+ expect(await menuValues(page, effort)).toEqual(["Adapter default", "max"]);
+
+ await dialog.getByRole("button", { name: "Save changes" }).click();
+ await expect(dialog).toHaveCount(0);
+ const updates = await payloadsFor(page, "update_managed_agent");
+ expect(updates).toHaveLength(1);
+ expect(updates[0].input).not.toHaveProperty("effortLevel");
+ });
+
+ test("clearing an unlisted stored level saves the adapter default", async ({
+ page,
+ }) => {
+ await install(page, {
+ managedAgents: [{ ...AGENT, model: "haiku" }],
+ agentConfigSurface: surface(undefined, "max"),
+ });
+ const dialog = await openEdit(page);
+ const effort = dialog.locator("#edit-agent-effort");
+ await expect(effort).toHaveText("max");
+ await pick(page, effort, "Adapter default");
+ await expect(effort).toHaveText("Adapter default");
+ await expect(dialog).not.toContainText("This model may not support max.");
+
+ await dialog.getByRole("button", { name: "Save changes" }).click();
+ await expect(dialog).toHaveCount(0);
+ const updates = await payloadsFor(page, "update_managed_agent");
+ expect(updates).toHaveLength(1);
+ expect(updates[0].input).toHaveProperty("effortLevel", null);
+ });
+
+ for (const clear of [false, true]) {
+ test(`failed discovery keeps a stored level visible (${clear ? "cleared" : "untouched"})`, async ({
+ page,
+ }) => {
+ await install(page, {
+ agentConfigSurface: surface(undefined, "max"),
+ discoverAgentModelsError: "discovery failed",
+ });
+ const dialog = await openEdit(page);
+ const effort = dialog.locator("#edit-agent-effort");
+ await expect(dialog).toContainText(DISCOVERY_FAILED);
+ await expect(effort).toHaveText("max");
+ await expect(dialog).toContainText("Support for max isn't known yet.");
+ expect(await menuValues(page, effort)).toEqual([
+ "Adapter default",
+ "max",
+ ]);
+ if (clear) {
+ await pick(page, effort, "Adapter default");
+ await expect(effort).toHaveText("Adapter default");
+ }
+
+ await dialog.getByRole("button", { name: "Save changes" }).click();
+ await expect(dialog).toHaveCount(0);
+ const updates = await payloadsFor(page, "update_managed_agent");
+ expect(updates).toHaveLength(1);
+ if (clear) expect(updates[0].input).toHaveProperty("effortLevel", null);
+ else expect(updates[0].input).not.toHaveProperty("effortLevel");
+ });
+
+ test(`a runtime round trip keeps a stored level visible (${clear ? "cleared" : "untouched"})`, async ({
+ page,
+ }) => {
+ await install(page, {
+ managedAgents: [{ ...AGENT, model: "claude-opus-4-8" }],
+ agentConfigSurface: surface(undefined, "max"),
+ discoverAgentModels: {
+ ...CLAUDE_MODELS,
+ models: [
+ ...CLAUDE_MODELS.models,
+ { id: "claude-opus-4-8", name: "Opus 4.8" },
+ ],
+ },
+ });
+ const dialog = await openEdit(page);
+ const effort = dialog.locator("#edit-agent-effort");
+ const runtime = dialog.locator("#edit-agent-runtime");
+ const model = dialog.locator("#edit-agent-model");
+ await expect(effort).toHaveText("max");
+ await pick(page, runtime, "Codex");
+ await pick(page, runtime, "Claude Code");
+ await pick(page, model, "Opus");
+ await expect(effort).toHaveText("max");
+ await pick(page, model, "Haiku");
+ await expect(effort).toHaveText("max");
+ await expect(dialog).toContainText("This model may not support max.");
+ if (clear) {
+ await pick(page, effort, "Adapter default");
+ await expect(effort).toHaveText("Adapter default");
+ }
+
+ await dialog.getByRole("button", { name: "Save changes" }).click();
+ await expect(dialog).toHaveCount(0);
+ const updates = await payloadsFor(page, "update_managed_agent");
+ expect(updates).toHaveLength(1);
+ if (clear) expect(updates[0].input).toHaveProperty("effortLevel", null);
+ else expect(updates[0].input).not.toHaveProperty("effortLevel");
+ });
+ }
+
+ test("a config-file effort is not shown as the agent's stored level", async ({
+ page,
+ }) => {
+ // ~/.claude/settings.json effortLevel reaches the surface as configFile.
+ await install(page, {
+ agentConfigSurface: surface(undefined, "max", "configFile"),
+ });
+ const dialog = await openEdit(page);
+ const effort = dialog.locator("#edit-agent-effort");
+ await expect(effort).toHaveText("Adapter default");
+ expect(await menuValues(page, effort)).toEqual(ADAPTER_LEVELS);
+ });
+
+ test("a config-file effort shows no picker for Haiku", async ({ page }) => {
+ await install(page, {
+ managedAgents: [{ ...AGENT, model: "haiku" }],
+ agentConfigSurface: surface(undefined, "max", "configFile"),
+ });
+ const dialog = await openEdit(page);
+ await expect(dialog.locator("#edit-agent-model")).toHaveText("Haiku");
+ await expect(dialog.locator("#edit-agent-effort")).toHaveCount(0);
+ });
+
+ test("a pick the new model drops shows the stored level Save keeps", async ({
+ page,
+ }) => {
+ await install(page, {
+ managedAgents: [{ ...AGENT, model: "claude-opus-4-8" }],
+ agentConfigSurface: surface(undefined, "max"),
+ discoverAgentModels: {
+ ...CLAUDE_MODELS,
+ models: [
+ ...CLAUDE_MODELS.models,
+ { id: "claude-opus-4-8", name: "Opus 4.8" },
+ ],
+ },
+ });
+ const dialog = await openEdit(page);
+ const effort = dialog.locator("#edit-agent-effort");
+ await pick(page, effort, "high");
+ await pick(page, dialog.locator("#edit-agent-model"), "Haiku");
+ await expect(effort).toHaveText("max");
+ await expect(dialog).toContainText("This model may not support max.");
+
+ await dialog.getByRole("button", { name: "Save changes" }).click();
+ await expect(dialog).toHaveCount(0);
+ const updates = await payloadsFor(page, "update_managed_agent");
+ expect(updates).toHaveLength(1);
+ expect(updates[0].input).not.toHaveProperty("effortLevel");
+ });
+
+ for (const clear of [false, true]) {
+ test(`a stored level survives a switch to another runtime (${clear ? "cleared" : "untouched"})`, async ({
+ page,
+ }) => {
+ // The backend keeps effort_level across a runtime switch.
+ await install(page, {
+ managedAgents: [{ ...AGENT, runtime: "codex" }],
+ agentConfigSurface: surface(undefined, "xhigh"),
+ });
+ const dialog = await openEdit(page);
+ const effort = dialog.locator("#edit-agent-effort");
+ await expect(effort).toHaveText("xhigh");
+ await pick(page, dialog.locator("#edit-agent-runtime"), "Claude Code");
+ await expect(effort).toHaveText("xhigh");
+ await expect(dialog).toContainText("This model may not support xhigh.");
+ expect(await menuValues(page, effort)).toEqual([
+ ...ADAPTER_LEVELS,
+ "xhigh",
+ ]);
+ if (clear) {
+ await pick(page, effort, "Adapter default");
+ await expect(effort).toHaveText("Adapter default");
+ }
+
+ await dialog.getByRole("button", { name: "Save changes" }).click();
+ await expect(dialog).toHaveCount(0);
+ const updates = await payloadsFor(page, "update_managed_agent");
+ expect(updates).toHaveLength(1);
+ expect(updates[0].input.agentCommand).toBe("claude");
+ if (clear) expect(updates[0].input).toHaveProperty("effortLevel", null);
+ else expect(updates[0].input).not.toHaveProperty("effortLevel");
+ });
+ }
+
+ test("a runtime switch while discovery is pending leaks no Claude levels", async ({
+ page,
+ }) => {
+ await install(page, { discoverAgentModelsDelayMs: 1_500 });
+ const dialog = await openEdit(page);
+ const effort = dialog.locator("#edit-agent-effort");
+ await expect(dialog.locator("#edit-agent-model")).toBeVisible();
+ await expect(effort).toHaveCount(0);
+
+ await pick(page, dialog.locator("#edit-agent-runtime"), "Codex");
+ await page.waitForTimeout(2_000);
+ await expect(effort).toHaveCount(0);
+ });
+});
+
+test.describe("create dialog", () => {
+ test("local create-and-start sends the picked effort", async ({ page }) => {
+ await install(page);
+ const dialog = await openCreate(page);
+ await pickCreateModel(page, dialog, "Opus");
+ await pick(page, dialog.locator("#edit-agent-effort"), "high");
+ await dialog.getByRole("button", { name: "Add agent" }).click();
+ await expect(dialog).toHaveCount(0);
+ const creates = await payloadsFor(page, "create_managed_agent");
+ expect(creates).toHaveLength(1);
+ expect(creates[0].input.effortLevel).toBe("high");
+ });
+
+ for (const baked of ["gpt-5.5", "claude-haiku-4-5"]) {
+ test(`a baked ${baked} build model does not decide Claude levels`, async ({
+ page,
+ }) => {
+ await install(page, bakedModel(baked));
+ const dialog = await openCreate(page);
+ expect(
+ await menuValues(page, dialog.locator("#edit-agent-effort")),
+ ).toEqual(ADAPTER_LEVELS);
+ });
+ }
+
+ for (const mode of ["pending", "failed"] as const) {
+ test(`a pick survives create while rediscovery is ${mode}`, async ({
+ page,
+ }) => {
+ await install(page);
+ const dialog = await openCreate(page);
+ const effort = dialog.locator("#edit-agent-effort");
+ await pick(page, effort, "high");
+
+ await stallDiscovery(page, mode);
+ await dialog.getByTestId("env-vars-add").click();
+ await dialog.getByTestId("env-vars-key").last().fill("EFFORT_TEST");
+ await dialog.getByTestId("env-vars-value").last().fill("two");
+ await expect(effort).toHaveCount(0);
+ // Create shows no discovery status; wait for the failing call instead.
+ await expect
+ .poll(async () =>
+ JSON.stringify(await payloadsFor(page, "discover_agent_models")),
+ )
+ .toContain("two");
+
+ await dialog.getByRole("button", { name: "Add agent" }).click();
+ await expect(dialog).toHaveCount(0);
+ const creates = await payloadsFor(page, "create_managed_agent");
+ expect(creates).toHaveLength(1);
+ expect(creates[0].input.effortLevel).toBe("high");
+ });
+ }
+
+ test("switching to Haiku hides the picker and sends no effort", async ({
+ page,
+ }) => {
+ await install(page);
+ const dialog = await openCreate(page);
+ await pickCreateModel(page, dialog, "Opus");
+ await pick(page, dialog.locator("#edit-agent-effort"), "high");
+ await pickCreateModel(page, dialog, "Haiku");
+ await expect(dialog.locator("#edit-agent-effort")).toHaveCount(0);
+ await dialog.getByRole("button", { name: "Add agent" }).click();
+ await expect(dialog).toHaveCount(0);
+ const creates = await payloadsFor(page, "create_managed_agent");
+ expect(creates).toHaveLength(1);
+ expect(creates[0].input).not.toHaveProperty("effortLevel");
+ });
+
+ test("switching to Haiku with Advanced collapsed sends no effort", async ({
+ page,
+ }) => {
+ await install(page);
+ const dialog = await openCreate(page);
+ await pickCreateModel(page, dialog, "Opus");
+ await pick(page, dialog.locator("#edit-agent-effort"), "high");
+ await dialog.getByRole("button", { name: "Advanced", exact: true }).click();
+ await expect(dialog.locator("#edit-agent-effort")).toHaveCount(0);
+ await pickCreateModel(page, dialog, "Haiku");
+ await dialog.getByRole("button", { name: "Add agent" }).click();
+ await expect(dialog).toHaveCount(0);
+ const creates = await payloadsFor(page, "create_managed_agent");
+ expect(creates).toHaveLength(1);
+ expect(creates[0].input).not.toHaveProperty("effortLevel");
+ });
+
+ test("a remote create hides the picker and sends no effort", async ({
+ page,
+ }) => {
+ await install(page, {
+ backendProviders: [
+ { id: "kubernetes", binaryPath: "/mock/buzz-backend-kubernetes" },
+ ],
+ backendProviderProbeResult: {
+ ok: true,
+ name: "kubernetes",
+ version: "0.0.0-mock",
+ config_schema: {
+ type: "object",
+ properties: { namespace: { type: "string", default: "agents" } },
+ required: ["namespace"],
+ },
+ },
+ });
+ const dialog = await openCreate(page);
+ await pickCreateModel(page, dialog, "Opus");
+ await pick(page, dialog.locator("#edit-agent-effort"), "high");
+ await pick(page, dialog.locator("#agent-run-on"), "kubernetes");
+ await expect(dialog.locator("#provider-cfg-namespace")).toHaveValue(
+ "agents",
+ );
+ await expect(dialog.locator("#edit-agent-effort")).toHaveCount(0);
+ await dialog.getByRole("button", { name: "Add agent" }).click();
+ await expect(dialog).toHaveCount(0);
+ const creates = await payloadsFor(page, "create_managed_agent");
+ expect(creates).toHaveLength(1);
+ expect(creates[0].input).not.toHaveProperty("effortLevel");
+ });
+
+ test("editing a definition shows no picker and saves no effort", async ({
+ page,
+ }) => {
+ await install(page, {
+ personas: [
+ {
+ id: "persona-opus",
+ displayName: "Opus Definition",
+ systemPrompt: "Original.",
+ runtime: "claude",
+ model: "opus[1m]",
+ },
+ ],
+ activePersonaIds: ["persona-opus"],
+ });
+ await page.goto("/");
+ await page.getByTestId("open-agents-view").click();
+ await page.getByLabel("Open actions for Opus Definition").click();
+ await page.getByRole("menuitem", { name: "Edit" }).click();
+ const dialog = page.getByTestId("persona-dialog");
+ await dialog.getByRole("button", { name: "Advanced", exact: true }).click();
+ await expect(dialog.getByTestId("agent-respond-to")).toBeVisible();
+ await expect(dialog.locator("#edit-agent-effort")).toHaveCount(0);
+ await dialog.getByLabel("Agent instruction").fill("Edited.");
+ await dialog.getByRole("button", { name: "Save changes" }).click();
+ await expect(dialog).toHaveCount(0);
+ const saves = await page.evaluate(() =>
+ (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter((entry) =>
+ /persona|managed_agent/.test(entry.command),
+ ),
+ );
+ expect(JSON.stringify(saves)).not.toContain("effortLevel");
+ });
+});
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index e945508c08..69bdb6bb0d 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -611,6 +611,10 @@ type MockBridgeOptions = {
* returning a catalog. Exercises the discovery-failure UI path.
*/
discoverAgentModelsError?: string;
+ /** Delay (ms) before `discover_agent_models` settles. */
+ discoverAgentModelsDelayMs?: number;
+ /** Config surface returned for every agent instead of the per-runtime mocks. */
+ agentConfigSurface?: Record;
/** ACP commands returned by `discover_acp_commands`. Defaults to `[]`. */
acpCommands?: Array<{ command: string; binaryPath: string }>;
/**