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, }),