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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-08 00:09:54 +08:00
1 джерело 3407195dc0
коміт 0d939c774d
8 files changed
+504 -27

No files matched your search

+84 -14
Переглянути файл
@@ -109,6 +109,9 @@ struct Shared {
struct Known {
#[serde(default)]
config: Vec<AgentConfigOption>,
/// Thinking levels reported for each model value, including its last choice.
#[serde(default, rename = "thoughtLevels")]
thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
/// By project root: skills and custom commands differ per project.
#[serde(default, rename = "projectCommands")]
commands: HashMap<String, Vec<AgentCommand>>,
@@ -128,10 +131,7 @@ impl Shared {
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
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();
}
+3
Переглянути файл
@@ -406,6 +406,9 @@ pub struct AgentProvider {
pub env: BTreeMap<String, String>,
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
pub config: Vec<AgentConfigOption>,
/// Thinking-level options by model value; absent models have not reported their levels.
#[serde(default)]
pub thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
/// Slash commands it offered last time.
pub commands: Vec<AgentCommand>,
pub source: AgentSource,
+1
Переглянути файл
@@ -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(),
}
+215 -5
Переглянути файл
@@ -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<Vec<Value>>,
model_options: Mutex<HashMap<String, Vec<Value>>>,
}
impl FakeAgent {
fn options_for(&self, model: &str) -> Vec<Value> {
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<Value> {
vec![json!({"id": "effort", "category": "thought_level", "type": "select", "currentValue": current,
"options": levels.iter().map(|v| json!({"value": v, "name": v})).collect::<Vec<_>>()})]
}
#[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::<Vec<_>>(), ["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::<Vec<_>>(), ["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<String, Known> =
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 =
+2
Переглянути файл
@@ -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,
+126
Переглянути файл
@@ -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 = <T,>(data: T) => Promise.resolve({ status: "ok", data });
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
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<typeof import("@/workbench/contributions")>()),
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(<HomeEditor />);
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(<HomeEditor />);
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(<HomeEditor />);
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(<HomeEditor />);
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" });
});
+15 -5
Переглянути файл
@@ -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 (
<div className={clsx(styles.home, docked && styles.docked)}>
@@ -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: {} })
}
/>
<ConfigPickers config={provider?.config ?? []} values={draft.config} onChange={setOption} />
<ConfigPickers config={config} onChange={setDraftOption} />
</>
}
actions={
+58 -3
Переглянути файл
@@ -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<string, string>;
/** Thinking-level choices by model value for this provider. */
thoughtLevels?: Record<string, Record<string, string>>;
isolation: Isolation;
/** The user picked `isolation`: otherwise a task runs in the local checkout. */
isolationChosen?: boolean;
@@ -134,11 +137,52 @@ export const useAgents = create<AgentsState>(() => ({
}));
export function updateDraft(patch: Partial<Draft>) {
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<ComposerDraft>) {
@@ -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,
}),