From 9c6fa14aa03358b4188f2ce0a92a9e435a3cb41b Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Tue, 6 Oct 2026 10:58:50 -0400
Subject: [PATCH] feat(desktop): offer Claude effort levels in agent edit and
create (#8059)
Claude Code agents now get a thinking-effort dropdown in the agent Edit
and Create dialogs, matching the one buzz-agent and Codex agents already
have.
## Behavior
- **Where levels come from:** Claude effort levels come from the Claude
model data for the model that will actually run after Save, resolved in
order: explicit model, then inherited default (linked definition or
global), then adapter default. Stored Claude session levels are not
used, because the stored session data does not record which model
produced them.
- **Edit:** for an agent linked to a definition, effort follows the
definition's model, which stays authoritative. Save never sends a model
for linked agents.
- **Create:** effort is offered for local agents only and is stored on
the instance. Allowed levels are recomputed on every render, so
collapsing Advanced and then switching to a model without effort (e.g.
Haiku) drops effort from the request. Non-local effort is rejected
before any create side effects.
- **Persistence:** `create_managed_agent` sets the picked effort when it
builds the record and drops stale effort env aliases. Its body runs over
any Tauri runtime, so a mock-app test drives the real create path and
reads the record back from disk. That test runs in a child process with
temp app-data and nest folders, and a test-only switch turns keychain
use off so agent keys stay inline in the temp agent file and the OS
keychain is never touched.
- **Observer store:** live session-config captures are written to the
store before the dialog refreshes; archived captures are read-only.
- **Codex:** its existing session-native effort picker is unchanged.
---------
Signed-off-by: Will Pfleger
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
---
desktop/playwright.config.ts | 1 +
.../src/commands/agent_models_update.rs | 4 +-
desktop/src-tauri/src/commands/agents.rs | 31 +-
.../src/commands/agents/provider_deploy.rs | 4 +-
.../src-tauri/src/commands/agents_tests.rs | 88 +++
.../src/commands/managed_agent_definition.rs | 22 +-
desktop/src-tauri/src/managed_agents/nest.rs | 9 +
.../src/managed_agents/persona_events.rs | 4 +-
.../src-tauri/src/managed_agents/storage.rs | 10 +
.../src/managed_agents/types/requests.rs | 3 +
desktop/src/features/agents/AGENTS.md | 57 +-
.../agents/lib/instanceInputForDefinition.ts | 2 +
.../src/features/agents/observerRelayStore.ts | 6 +-
.../agents/sessionConfigCapture.test.mjs | 82 +++
.../agents/ui/AgentDefinitionDialog.tsx | 22 +-
.../src/features/agents/ui/AgentDialog.tsx | 44 +-
.../agents/ui/AgentInstanceEditDialog.tsx | 117 ++-
desktop/src/features/agents/ui/AgentsView.tsx | 1 +
.../features/agents/ui/EffortPickerField.tsx | 44 +-
.../agents/ui/RequestedAgentCreateDialogs.tsx | 10 +-
.../ui/agentInstanceEditCancelSafety.test.mjs | 197 +++++-
.../ui/agentInstanceEditPinning.test.mjs | 15 +
.../features/agents/ui/effortPicker.test.mjs | 203 +++++-
.../src/features/agents/ui/effortPicker.ts | 161 ++++-
.../features/agents/ui/personaRuntimeModel.ts | 10 +-
.../features/agents/ui/usePersonaActions.ts | 2 +
.../agents/ui/usePersonaModelDiscovery.ts | 2 +
.../src/features/agents/useAgentManagement.ts | 2 +
desktop/src/shared/api/tauri.ts | 1 +
desktop/src/shared/api/types.ts | 2 +
desktop/src/testing/e2eBridge.ts | 14 +-
.../tests/e2e/claude-effort-picker.spec.ts | 668 ++++++++++++++++++
desktop/tests/helpers/bridge.ts | 4 +
33 files changed, 1658 insertions(+), 184 deletions(-)
create mode 100644 desktop/src/features/agents/sessionConfigCapture.test.mjs
create mode 100644 desktop/tests/e2e/claude-effort-picker.spec.ts
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 }>;
/**