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 }>; /**