From 0d939c774d546343280a6843858f952b2f239aeb Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:09:54 +0800 Subject: [PATCH] feat(agents): choose the thinking level before a task starts (#2023) Closes #1989. #1846 stopped offering the previous model's thinking levels on the New Task page by dropping thought_level from the provider-wide remembered config, which also removed the choice before the first message. Thinking levels (and the last choice) are now remembered per provider and model; the New Task page shows the levels of the selected model, hides the picker for models with unknown levels, and at start applies the model first, then the effort only if the session offers that value. --- crates/ide-agent/src/lib.rs | 98 +++++++++-- crates/ide-agent/src/model.rs | 3 + crates/ide-agent/src/provider.rs | 1 + crates/ide-agent/src/tests.rs | 220 +++++++++++++++++++++++- src/bindings.ts | 2 + src/features/agents/HomeEditor.test.tsx | 126 ++++++++++++++ src/features/agents/HomeEditor.tsx | 20 ++- src/features/agents/store.ts | 61 ++++++- 8 files changed, 504 insertions(+), 27 deletions(-) create mode 100644 src/features/agents/HomeEditor.test.tsx diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index eb1259220..ff4fa9753 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -109,6 +109,9 @@ struct Shared { struct Known { #[serde(default)] config: Vec, + /// Thinking levels reported for each model value, including its last choice. + #[serde(default, rename = "thoughtLevels")] + thought_levels: BTreeMap>, /// By project root: skills and custom commands differ per project. #[serde(default, rename = "projectCommands")] commands: HashMap>, @@ -128,10 +131,7 @@ impl Shared { fn known_config(&self, provider: &str) -> Vec { let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default(); - // A thinking level's values depend on the model (low/medium/high on one, none or a - // different set on another), but what ran last is remembered wholesale — after a - // model switch the New Task page would offer the previous model's levels. Until the - // session reports the new model's own, don't show the option at all. + // Model-specific options are offered separately, never as provider-wide defaults. config.retain(|o| o.category.as_deref() != Some("thought_level")); config::add_client_options(provider, &mut config); if let Some((o, v)) = config::bypass_mode(&config).map(|(o, v)| (o.id.clone(), v.value.clone())) @@ -155,14 +155,31 @@ impl Shared { self.paths.project_root.to_string_lossy().into_owned() } - fn remember(&self, provider: &str, config: Option<&[AgentConfigOption]>, commands: Option<&[AgentCommand]>) { + /// Cache levels only when `model` confirms which model the session reported them for. + fn remember( + &self, + provider: &str, + config: Option<&[AgentConfigOption]>, + commands: Option<&[AgentCommand]>, + model: Option<&str>, + ) { let snapshot = { let mut cache = self.known.lock(); let known = cache.entry(provider.to_owned()).or_default(); let mut changed = false; - if let Some(c) = config.filter(|c| !c.is_empty() && known.config != *c) { - known.config = c.to_vec(); - changed = true; + if let Some(c) = config { + if let Some(model) = model { + let levels: Vec<_> = + c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect(); + if known.thought_levels.get(model) != Some(&levels) { + known.thought_levels.insert(model.to_owned(), levels); + changed = true; + } + } + if !c.is_empty() && known.config != c { + known.config = c.to_vec(); + changed = true; + } } let root = self.root_key(); if let Some(c) = @@ -340,6 +357,8 @@ impl AgentManager { .map(|p| { let mut info = provider::describe(p); info.config = self.shared.known_config(&p.id); + info.thought_levels = + self.shared.known.lock().get(&p.id).map(|k| k.thought_levels.clone()).unwrap_or_default(); info.commands = self.shared.known_commands(&p.id); info }) @@ -459,6 +478,18 @@ impl AgentManager { o.current = Some(choice.value.clone()); } } + if let Some(model) = model_of(&config) + && let Some(levels) = self.shared.known.lock().get(&spec.id).and_then(|k| k.thought_levels.get(&model)) + { + config.extend(levels.iter().cloned().map(|mut o| { + if let Some(choice) = + req.config.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value)) + { + o.current = Some(choice.value.clone()); + } + o + })); + } let now = now_ms(); let task = AgentTask { project_root, @@ -606,6 +637,8 @@ impl AgentManager { o.clone() }; let live = task.live.lock().clone().filter(|l| !l.peer.is_closed()); + let mut model_options = + target.category.as_deref() != Some("model") || target.current.as_deref() == Some(value.as_str()); if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) { let (method, mut params) = config::set_request(&target, &value); params["sessionId"] = json!(live.session); @@ -613,10 +646,14 @@ impl AgentManager { live.peer.request(method, params, None).await.map_err(|e| IdeError::internal(task.describe(e)))?; if let Some(options) = reply.get("configOptions") { config::merge_update(&mut task.state.lock().task.config, options); + model_options = true; } } { let mut s = task.state.lock(); + if !model_options { + s.task.config.retain(|o| o.category.as_deref() != Some("thought_level")); + } s.wanted.retain(|c| c.id != option); s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.clone() }); if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) { @@ -625,8 +662,9 @@ impl AgentManager { s.task.model = model_of(&s.task.config).or(s.task.model.take()); let provider = s.task.provider.clone(); let config = s.task.config.clone(); + let model = s.task.model.clone().filter(|_| model_options); drop(s); - task.shared.remember(&provider, Some(&config), None); + task.shared.remember(&provider, Some(&config), None, model.as_deref()); } task.approve_pending(); task.persist(); @@ -1396,11 +1434,21 @@ impl Task { // A session that reports none keeps the last known ones, but what is set is unknown. let reported = config::options_of(&result); let known = !reported.is_empty(); - let (mut options, wanted) = { + if known { + self.shared.remember(&provider_id, Some(&reported), None, model_of(&reported).as_deref()); + } + let (mut options, mut wanted) = { let s = self.state.lock(); (if known { reported } else { s.task.config.clone() }, s.wanted.clone()) }; config::add_client_options(&provider_id, &mut options); + // Changing model can replace the effort choices; validate those only afterwards. + wanted.sort_by_key(|c| match options.iter().find(|o| o.id == c.id).and_then(|o| o.category.as_deref()) { + Some("model") => 0, + Some("thought_level") => 2, + _ => 1, + }); + let mut model_options = known; for choice in &wanted { // An option the session doesn't offer yet is set once it does (`apply_late_choices`). let Some(o) = options.iter().find(|o| o.id == choice.id).cloned() else { continue }; @@ -1410,6 +1458,9 @@ impl Task { } continue; } + if o.category.as_deref() == Some("thought_level") && !model_options { + continue; + } let already = known && o.current.as_deref() == Some(choice.value.as_str()); if already || !o.values.iter().any(|v| v.value == choice.value) { continue; @@ -1421,6 +1472,12 @@ impl Task { Ok(reply) => { if let Some(fresh) = reply.get("configOptions") { config::merge_update(&mut options, fresh); + model_options = true; + } else if o.category.as_deref() == Some("model") { + if o.current.as_deref() != Some(choice.value.as_str()) { + options.retain(|o| o.category.as_deref() != Some("thought_level")); + } + model_options = false; } if let Some(o) = options.iter_mut().find(|o| o.id == choice.id) { o.current = Some(choice.value.clone()); @@ -1429,7 +1486,8 @@ impl Task { Err(e) => tracing::warn!("agent task {}: setting {} to {} failed: {e}", self.id, o.id, choice.value), } } - self.shared.remember(&provider_id, Some(&options), None); + let model = model_of(&options).filter(|_| model_options); + self.shared.remember(&provider_id, Some(&options), None, model.as_deref()); { let mut s = self.state.lock(); s.task.model = model_of(&options).or(s.task.model.take()); @@ -1478,11 +1536,19 @@ impl Task { let mut s = task.state.lock(); if let Some(fresh) = reply.get("configOptions") { config::merge_update(&mut s.task.config, fresh); + } else if option.category.as_deref() == Some("model") { + s.task.config.retain(|o| o.category.as_deref() != Some("thought_level")); } if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option.id) { o.current = Some(value); } s.task.model = model_of(&s.task.config).or(s.task.model.take()); + let model = s.task.model.clone().filter(|_| { + reply.get("configOptions").is_some() || option.category.as_deref() != Some("model") + }); + let (provider, options) = (s.task.provider.clone(), s.task.config.clone()); + drop(s); + task.shared.remember(&provider, Some(&options), None, model.as_deref()); } Err(e) => tracing::warn!("agent task {}: setting {} to {value} failed: {e}", task.id, option.id), } @@ -1581,11 +1647,15 @@ impl Task { } }; if task_changed { - let (config, commands) = { + let (config, commands, model) = { let s = self.state.lock(); - (s.task.config.clone(), s.task.commands.clone()) + ( + s.task.config.clone(), + s.task.commands.clone(), + s.task.model.clone().filter(|_| kind == "config_option_update"), + ) }; - self.shared.remember(&provider, Some(&config), Some(&commands)); + self.shared.remember(&provider, Some(&config), Some(&commands), model.as_deref()); if kind != "usage_update" { self.persist(); } diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index 50edd1392..e45cafbe2 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -406,6 +406,9 @@ pub struct AgentProvider { pub env: BTreeMap, /// Settings (models, modes…) the agent offered last time it ran; empty before its first session. pub config: Vec, + /// Thinking-level options by model value; absent models have not reported their levels. + #[serde(default)] + pub thought_levels: BTreeMap>, /// Slash commands it offered last time. pub commands: Vec, pub source: AgentSource, diff --git a/crates/ide-agent/src/provider.rs b/crates/ide-agent/src/provider.rs index 14d388d58..ab97a4a7d 100644 --- a/crates/ide-agent/src/provider.rs +++ b/crates/ide-agent/src/provider.rs @@ -184,6 +184,7 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider { installed: installed(&spec.command), env: spec.env.iter().cloned().collect(), config: vec![], + thought_levels: BTreeMap::new(), commands: vec![], source: spec.source.clone(), } diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index 4f9116546..c0fff069c 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -12,11 +12,23 @@ struct FakeAgent { /// Without `loadSession` (a session lives only as long as its process). no_load: bool, auth_on_new: bool, + empty_config: AtomicBool, /// Config options `session/new` reports after the model one (per-model efforts, say). extra_options: Mutex>, + model_options: Mutex>>, } impl FakeAgent { + fn options_for(&self, model: &str) -> Vec { + let mut options = vec![json!({"id": "model", "category": "model", "type": "select", + "currentValue": model, + "options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"}, + {"value": "plain", "name": "Plain"}, {"value": "unknown", "name": "Unknown"}]})]; + options + .extend(self.model_options.lock().get(model).cloned().unwrap_or_else(|| self.extra_options.lock().clone())); + options + } + /// The connection opened last: the agent restarted, or a fork's. fn latest(&self) -> Peer { self.servers.lock().last().cloned().expect("a connection") @@ -103,10 +115,10 @@ impl Handler for FakeAgent { "sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}})) } "session/new" => { - let mut options = vec![json!({"id": "model", "category": "model", "type": "select", - "currentValue": "big", - "options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"}]})]; - options.extend(self.extra_options.lock().iter().cloned()); + if self.empty_config.load(Ordering::Acquire) { + return Ok(json!({"sessionId": "s1"})); + } + let options = self.options_for("big"); Ok(json!({"sessionId": "s1", "modes": {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}, {"id": "code", "name": "Code"}]}, "configOptions": options})) @@ -127,6 +139,9 @@ impl Handler for FakeAgent { } "session/set_config_option" | "session/set_mode" => { self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap())); + if params["configId"] == "model" && !self.model_options.lock().is_empty() { + return Ok(json!({"configOptions": self.options_for(params["value"].as_str().unwrap())})); + } Ok(json!({})) } "session/prompt" => { @@ -912,6 +927,22 @@ async fn usage_limits_and_late_settings() { assert!(agent.calls.lock().iter().any(|c| c == "session/set_config_option \"high\"")); let config = mgr.task(&task.id).unwrap().state.lock().task.config.clone(); assert_eq!(config.iter().find(|o| o.id == "effort").unwrap().current.as_deref(), Some("high")); + for _ in 0..100 { + if mgr + .shared + .known + .lock() + .get("claude") + .and_then(|k| k.thought_levels.get("big")) + .and_then(|levels| levels.first()) + .and_then(|o| o.current.as_deref()) + == Some("high") + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["big"][0].current.as_deref(), Some("high")); } #[tokio::test(flavor = "multi_thread")] @@ -919,7 +950,7 @@ async fn remembered_settings_drop_per_model_choices_for_the_next_task() { let tmp = tempfile::tempdir().unwrap(); let agent = Arc::new(FakeAgent::default()); // The effort list depends on the model: what the last session ran means nothing once - // the user picks another model, so the New Task page shows the option without a choice. + // the user picks another model, so provider-wide defaults never include it. *agent.extra_options.lock() = vec![json!({"id": "effort", "category": "thought_level", "type": "select", "currentValue": "high", "options": [{"value": "low", "name": "Low"}, {"value": "high", "name": "High"}]})]; @@ -943,6 +974,185 @@ async fn remembered_settings_drop_per_model_choices_for_the_next_task() { assert_eq!(model.current.as_deref(), Some("big"), "the model choice survives"); } +fn effort_options(levels: &[&str], current: &str) -> Vec { + vec![json!({"id": "effort", "category": "thought_level", "type": "select", "currentValue": current, + "options": levels.iter().map(|v| json!({"value": v, "name": v})).collect::>()})] +} + +#[tokio::test(flavor = "multi_thread")] +async fn thinking_levels_are_remembered_per_provider_and_model() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + *agent.model_options.lock() = HashMap::from([ + ("big".into(), effort_options(&["low", "high"], "high")), + ("small".into(), effort_options(&["minimal", "medium"], "minimal")), + ("plain".into(), vec![]), + ]); + let mgr = manager(tmp.path(), agent.clone()); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("again"), + provider: "claude".into(), + config: vec![], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + mgr.set_config(&task.id, "model", "small".into()).await.unwrap(); + mgr.set_config(&task.id, "effort", "medium".into()).await.unwrap(); + mgr.set_config(&task.id, "model", "plain".into()).await.unwrap(); + let p = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap(); + let big = &p.thought_levels["big"][0]; + assert_eq!(big.current.as_deref(), Some("high")); + assert_eq!(big.values.iter().map(|v| v.value.as_str()).collect::>(), ["low", "high"]); + let small = &p.thought_levels["small"][0]; + assert_eq!(small.current.as_deref(), Some("medium")); + assert_eq!(small.values.iter().map(|v| v.value.as_str()).collect::>(), ["minimal", "medium"]); + assert!(p.thought_levels["plain"].is_empty()); + assert!(!p.thought_levels.contains_key("unknown")); + assert!(mgr.providers().iter().find(|p| p.id == "codex").unwrap().thought_levels.is_empty()); + let saved: HashMap = + serde_json::from_slice(&std::fs::read(&mgr.shared.paths.models_file).unwrap()).unwrap(); + assert_eq!(saved["claude"].thought_levels, p.thought_levels); + mgr.shutdown(); + let restored = manager(tmp.path(), agent); + assert_eq!(restored.providers().into_iter().find(|p| p.id == "claude").unwrap().thought_levels, p.thought_levels); + restored.shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn upfront_effort_is_validated_after_changing_model_before_the_prompt() { + for (effort, applied) in [("medium", true), ("high", false)] { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + *agent.model_options.lock() = HashMap::from([ + ("big".into(), effort_options(&["low", "high"], "low")), + ("small".into(), effort_options(&["minimal", "medium"], "minimal")), + ]); + let mgr = manager(tmp.path(), agent.clone()); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("again"), + provider: "claude".into(), + // Reversed on purpose: model must be set before validating effort. + config: vec![ + AgentConfigChoice { id: "effort".into(), value: effort.into() }, + AgentConfigChoice { id: "model".into(), value: "small".into() }, + ], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + let calls = agent.calls.lock(); + let model = calls.iter().position(|c| c == "session/set_config_option \"small\"").unwrap(); + let effort_call = calls.iter().position(|c| c == &format!("session/set_config_option \"{effort}\"")); + assert_eq!(effort_call.is_some(), applied); + if let Some(effort_call) = effort_call { + let prompt = calls.iter().position(|c| c == "session/prompt").unwrap(); + assert!(model < effort_call && effort_call < prompt); + } + drop(calls); + mgr.shutdown(); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn unreported_model_switch_does_not_cache_the_previous_models_levels() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + *agent.extra_options.lock() = effort_options(&["low", "high"], "high"); + let mgr = manager(tmp.path(), agent); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("again"), + provider: "claude".into(), + config: vec![], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + mgr.set_config(&task.id, "model", "small".into()).await.unwrap(); + let p = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap(); + assert!(!p.thought_levels.contains_key("small")); + assert_eq!(p.thought_levels["big"][0].current.as_deref(), Some("high")); + mgr.shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cached_effort_is_not_sent_without_real_session_options() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + *agent.extra_options.lock() = effort_options(&["low", "high"], "low"); + let mgr = manager(tmp.path(), agent.clone()); + let new = |config| NewAgentTask { + project_root: None, + prompt: text("again"), + provider: "claude".into(), + config, + isolation: Isolation::Local, + base: None, + }; + let warm = mgr.create(new(vec![])).await.unwrap(); + wait_for(&mgr, &warm.id, AgentStatus::Ready).await; + agent.calls.lock().clear(); + agent.empty_config.store(true, Ordering::Release); + let task = mgr + .create(new(vec![ + AgentConfigChoice { id: "model".into(), value: "big".into() }, + AgentConfigChoice { id: "effort".into(), value: "high".into() }, + ])) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + assert!(!agent.calls.lock().iter().any(|c| c == "session/set_config_option \"high\"")); + assert!( + mgr.task(&task.id).unwrap().state.lock().task.config.iter().any(|o| o.id == "effort"), + "remembered session settings remain visible until the agent refreshes them" + ); + mgr.shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn reported_levels_refresh_even_without_a_model_option() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + *agent.extra_options.lock() = effort_options(&["low", "high"], "high"); + let mgr = manager(tmp.path(), agent.clone()); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("again"), + provider: "claude".into(), + config: vec![], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + for levels in [effort_options(&["minimal", "medium"], "medium"), vec![]] { + agent.update("s1", json!({"sessionUpdate": "config_option_update", "configOptions": levels})); + let expected = config::parse_options(&json!(levels)); + for _ in 0..100 { + if mgr.shared.known.lock()["claude"].thought_levels["big"] == expected { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["big"], expected); + } + mgr.shutdown(); +} + #[test] fn retry_updates_become_a_countdown() { let update = diff --git a/src/bindings.ts b/src/bindings.ts index 3afdf3b0c..733358c83 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1634,6 +1634,8 @@ export type AgentProvider = { env: { [key in string]: string }, /** Settings (models, modes…) the agent offered last time it ran; empty before its first session. */ config: AgentConfigOption[], + /** Thinking-level options by model value; absent models have not reported their levels. */ + thoughtLevels?: { [key in string]: AgentConfigOption[] }, /** Slash commands it offered last time. */ commands: AgentCommand[], source: AgentSource, diff --git a/src/features/agents/HomeEditor.test.tsx b/src/features/agents/HomeEditor.test.tsx new file mode 100644 index 000000000..57970f1cd --- /dev/null +++ b/src/features/agents/HomeEditor.test.tsx @@ -0,0 +1,126 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import type { AgentConfigOption, AgentProvider, NewAgentTask } from "@/bindings"; +import { HomeEditor } from "./HomeEditor"; +import { HOME, savedDraft, updateComposer, useAgents } from "./store"; + +const backend = vi.hoisted(() => ({ providers: [] as AgentProvider[], create: vi.fn() })); +const ok = (data: T) => Promise.resolve({ status: "ok", data }); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original()), + commands: { + agentProviders: () => ok(backend.providers), + agentProbe: () => ok(null), + vcsBranchInfo: () => ok({ head: "main" }), + vcsBranches: () => ok([]), + agentCreate: (req: NewAgentTask) => backend.create(req), + }, +})); +vi.mock("@/workbench/contributions", async (original) => ({ + ...(await original()), + maybeEditorService: () => ({ activePath: () => null }), + editorService: () => ({ closeInput: () => false }), +})); + +const option = (id: string, category: string, values: string[], current: string): AgentConfigOption => ({ + id, + category, + name: id === "model" ? "Model" : "Reasoning", + description: null, + current, + via: "option", + values: values.map((value) => ({ value, name: value, description: null })), +}); + +beforeEach(() => { + backend.create.mockReset().mockImplementation((req: NewAgentTask) => ok({ id: "new", config: req.config })); + backend.providers = [ + { + id: "fake", + name: "Fake", + command: "fake", + program: "fake", + args: [], + defaultProgram: null, + defaultArgs: [], + loginHint: null, + commandOverridden: false, + commandEditable: true, + commandReadOnlyReason: null, + installed: true, + env: {}, + commands: [], + source: { kind: "custom" }, + config: [option("model", "model", ["big", "small", "plain", "unknown"], "big")], + thoughtLevels: { + big: [option("effort", "thought_level", ["low", "high"], "high")], + small: [option("effort", "thought_level", ["minimal", "medium", "max"], "minimal")], + plain: [], + }, + }, + ]; + useAgents.setState({ providers: backend.providers, draft: savedDraft({ provider: "fake", config: {} }) }); + updateComposer(HOME, { text: "Check the project" }); +}); + +async function chooseModel(model: string) { + const button = screen.getByRole("button", { name: "Model" }); + if (button.getAttribute("aria-haspopup") === "dialog") { + fireEvent.click(button); + fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Model" })); + } else fireEvent.click(button); + fireEvent.click(screen.getByRole("option", { name: model })); + fireEvent.pointerDown(document.body); +} + +it("switches model-specific levels and restores each draft effort before starting", async () => { + render(); + expect(screen.getByRole("button", { name: "Model" })).toHaveTextContent("bighigh"); + await chooseModel("small"); + fireEvent.click(screen.getByRole("button", { name: "Model" })); + const slider = screen.getByRole("slider", { name: "Reasoning" }); + expect(slider).toHaveAttribute("max", "2"); + expect(slider).toHaveAttribute("aria-valuetext", "minimal"); + fireEvent.change(slider, { target: { value: "1" } }); + fireEvent.keyDown(document, { key: "Escape" }); + await chooseModel("big"); + expect(screen.getByRole("button", { name: "Model" })).toHaveTextContent("bighigh"); + await chooseModel("small"); + expect(screen.getByRole("button", { name: "Model" })).toHaveTextContent("smallmedium"); + fireEvent.click(screen.getByRole("button", { name: "Start" })); + await waitFor(() => expect(backend.create).toHaveBeenCalled()); + expect(backend.create.mock.calls[0]![0].config).toEqual([ + { id: "model", value: "small" }, + { id: "effort", value: "medium" }, + ]); +}); + +it.each(["plain", "unknown"])("hides effort for %s models and sends no stale effort", async (model) => { + render(); + await chooseModel(model); + expect(screen.getByRole("button", { name: "Model" })).toHaveAttribute("aria-haspopup", "listbox"); + fireEvent.click(screen.getByRole("button", { name: "Start" })); + await waitFor(() => expect(backend.create).toHaveBeenCalled()); + expect(backend.create.mock.calls[0]![0].config).toEqual([{ id: "model", value: model }]); +}); + +it("sends the displayed remembered model and effort even when neither is changed", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Start" })); + await waitFor(() => expect(backend.create).toHaveBeenCalled()); + expect(backend.create.mock.calls[0]![0].config).toEqual([ + { id: "model", value: "big" }, + { id: "effort", value: "high" }, + ]); +}); + +it("ignores old provider-wide effort choices and refreshes unsupported per-model choices", async () => { + useAgents.setState({ + draft: savedDraft({ provider: "fake", config: { effort: "low" }, thoughtLevels: { big: { effort: "removed" } } }), + }); + render(); + expect(screen.getByRole("button", { name: "Model" })).toHaveTextContent("bighigh"); + fireEvent.click(screen.getByRole("button", { name: "Start" })); + await waitFor(() => expect(backend.create).toHaveBeenCalled()); + expect(backend.create.mock.calls[0]![0].config).toContainEqual({ id: "effort", value: "high" }); +}); diff --git a/src/features/agents/HomeEditor.tsx b/src/features/agents/HomeEditor.tsx index 85a552934..119b4ca7b 100644 --- a/src/features/agents/HomeEditor.tsx +++ b/src/features/agents/HomeEditor.tsx @@ -15,7 +15,17 @@ import { canSend, emptyDraft } from "./composerModel"; import { ConfigPickers } from "./ConfigPickers"; import { providerIcon } from "./model"; import { ADD_AGENT, providerOptions as agentOptions } from "./providerOptions"; -import { HOME, NO_COMMANDS, refreshProviders, startTask, updateDraft, useAgentCommands, useAgents } from "./store"; +import { + HOME, + NO_COMMANDS, + draftConfig, + refreshProviders, + setDraftOption, + startTask, + updateDraft, + useAgentCommands, + useAgents, +} from "./store"; import styles from "./Home.module.css"; export function HomeEditor({ docked = false }: { docked?: boolean; input?: EditorInput }) { @@ -85,7 +95,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e))) .finally(() => setStarting(false)); }; - const setOption = (id: string, value: string) => updateDraft({ config: { ...draft.config, [id]: value } }); + const config = draftConfig(provider, draft); return (
@@ -107,8 +117,8 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito projectRoot={root} commands={agentCommands} history={history} - config={provider?.config ?? []} - onConfig={setOption} + config={config} + onConfig={setDraftOption} onSubmit={start} disabled={!canStart} toolbar={ @@ -122,7 +132,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito id === ADD_AGENT ? void executeAction("agents.addAgent") : updateDraft({ provider: id, config: {} }) } /> - + } actions={ diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index 2ba53f2be..e7f5a17ed 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -5,6 +5,7 @@ import { create } from "zustand"; import type { AgentChange, AgentCommand, + AgentConfigOption, AgentEntry, AgentEvent, AgentImage, @@ -37,6 +38,8 @@ export interface Draft { provider: string; /** Settings chosen for the next task, by option id (reset when the agent changes). */ config: Record; + /** Thinking-level choices by model value for this provider. */ + thoughtLevels?: Record>; isolation: Isolation; /** The user picked `isolation`: otherwise a task runs in the local checkout. */ isolationChosen?: boolean; @@ -134,11 +137,52 @@ export const useAgents = create(() => ({ })); export function updateDraft(patch: Partial) { - const draft = { ...useAgents.getState().draft, ...patch }; + const current = useAgents.getState().draft; + const draft = { + ...current, + ...(patch.provider && patch.provider !== current.provider ? { thoughtLevels: {} } : {}), + ...patch, + }; useAgents.setState({ draft }); save(DRAFT_KEY, draft); } +/** Config for the selected model, with only supported draft choices applied. */ +export function draftConfig(provider: AgentProvider | undefined, draft: Draft): AgentConfigOption[] { + const config = (provider?.config ?? []) + .filter((o) => o.category !== "thought_level") + .map((o) => { + const choice = draft.config[o.id]; + return { ...o, current: choice !== undefined && o.values.some((v) => v.value === choice) ? choice : o.current }; + }); + const model = config.find((o) => o.category === "model")?.current; + if (model) { + config.push( + ...(provider?.thoughtLevels?.[model] ?? []).map((o) => { + const choice = draft.thoughtLevels?.[model]?.[o.id]; + return { ...o, current: choice !== undefined && o.values.some((v) => v.value === choice) ? choice : o.current }; + }), + ); + } + return config; +} + +export function setDraftOption(id: string, value: string) { + const { draft, providers } = useAgents.getState(); + const config = draftConfig( + providers.find((p) => p.id === draft.provider), + draft, + ); + const model = config.find((o) => o.category === "model")?.current; + if (model && config.find((o) => o.id === id)?.category === "thought_level") { + updateDraft({ + thoughtLevels: { ...draft.thoughtLevels, [model]: { ...draft.thoughtLevels?.[model], [id]: value } }, + }); + } else { + updateDraft({ config: { ...draft.config, [id]: value } }); + } +} + export const composerDraft = (key: string) => useAgents.getState().composers.get(key) ?? emptyDraft(); export function updateComposer(key: string, patch: Partial) { @@ -485,13 +529,24 @@ export async function startTask( const { draft, providers } = useAgents.getState(); const composer = composerDraft(HOME); if (!canSend(composer)) return; - const config = { ...bypassDefaults(providers.find((p) => p.id === draft.provider)?.config ?? []), ...draft.config }; + const options = draftConfig( + providers.find((p) => p.id === draft.provider), + draft, + ); + const defaults = bypassDefaults(options); + const config = options.flatMap((o) => { + const value = + o.category === "model" || o.category === "thought_level" + ? o.current + : (draft.config[o.id] ?? defaults[o.id] ?? null); + return value !== null && o.values.some((v) => v.value === value) ? [{ id: o.id, value }] : []; + }); const task = await call( commands.agentCreate({ projectRoot, prompt: toPrompt(composer), provider: draft.provider, - config: Object.entries(config).map(([id, value]) => ({ id, value })), + config, isolation, base: isolation === "worktree" ? draft.base : null, }),