From 97779f2805c1398961e0c02b9125ecb29379d172 Mon Sep 17 00:00:00 2001 From: Victor Villlalobos Date: Tue, 13 Oct 2026 10:59:29 -0300 Subject: [PATCH 1/2] fix(chat): coming back to a conversation by its row does not ask to resume it Once the agent had written its session, the conversation's row was the history session's, and a click on it only looked for a terminal: it offered to start the agent again in one. A history row whose conversation is open is that conversation's, as it is a terminal's, also for the context menu, the keys that act on the session under the cursor and the Den. --- crates/app/src/ui/chat.rs | 10 ++++++++++ crates/app/src/ui/den_view.rs | 2 +- crates/app/src/ui/history_sync.rs | 2 +- crates/app/src/ui/menu.rs | 6 +++--- crates/app/src/ui/shell.rs | 10 +++++++--- crates/app/src/ui/terminals.rs | 19 ++++++------------- crates/app/src/ui/tests_chat.rs | 20 ++++++++++++++++++++ 7 files changed, 48 insertions(+), 21 deletions(-) diff --git a/crates/app/src/ui/chat.rs b/crates/app/src/ui/chat.rs index f25147a..81b3df6 100644 --- a/crates/app/src/ui/chat.rs +++ b/crates/app/src/ui/chat.rs @@ -995,6 +995,16 @@ impl Shell { } } + /// What runs here for this history session: the terminal that has it, or + /// the conversation. A history row with either is that session's row, and + /// is brought forward, never started a second time. + pub(super) fn running_of_history(&self, history: &SessionId) -> Option { + self.live + .of_history(history) + .map(|live| live.id) + .or_else(|| self.chat_of_history(history)) + } + /// The conversation started from this history session, if one is open. pub(super) fn chat_of_history(&self, history: &SessionId) -> Option { self.chats diff --git a/crates/app/src/ui/den_view.rs b/crates/app/src/ui/den_view.rs index 6fd1652..4ffec0c 100644 --- a/crates/app/src/ui/den_view.rs +++ b/crates/app/src/ui/den_view.rs @@ -921,7 +921,7 @@ impl Shell { .iter() .filter(|stored| self.slept.contains(&stored.id)) // One that runs again is in the room, not at home. - .filter(|stored| self.live.of_history(&stored.id).is_none()) + .filter(|stored| self.running_of_history(&stored.id).is_none()) .map(|stored| { ( Home::Slept(stored.id.clone()), diff --git a/crates/app/src/ui/history_sync.rs b/crates/app/src/ui/history_sync.rs index 40654ee..ee3eca5 100644 --- a/crates/app/src/ui/history_sync.rs +++ b/crates/app/src/ui/history_sync.rs @@ -211,7 +211,7 @@ impl Shell { // A history row is one terminal's: never two. let history = item .history - .filter(|row| self.live.of_history(row).is_none()); + .filter(|row| self.running_of_history(row).is_none()); if let Some(session) = self.live.get_mut(LiveId(item.id)) { // An id already learned keeps how it was learned. if item.how != "id" { diff --git a/crates/app/src/ui/menu.rs b/crates/app/src/ui/menu.rs index 9167d4f..56f3a43 100644 --- a/crates/app/src/ui/menu.rs +++ b/crates/app/src/ui/menu.rs @@ -456,7 +456,7 @@ impl Shell { // by default: the transcript comes first, and resuming is "Resume // here anyway". if let (Some(_), Kind::Session(session)) = (held, &row.kind) { - if self.live.of_history(&session.id).is_none() { + if self.running_of_history(&session.id).is_none() { items.retain(|item| item.command != Command::ResumeSession); } } @@ -464,7 +464,7 @@ impl Shell { // with what that state does: running, asleep, or history only (which // `items_for_held` already is). if let Kind::Session(session) = &row.kind { - if self.live.of_history(&session.id).is_some() { + if self.running_of_history(&session.id).is_some() { // The terminal's row: it is closed from here as a live row // is, and removing it from the history would only bring the // terminal back as a live row, so that is not offered. @@ -503,7 +503,7 @@ impl Shell { item.command = Command::BringBackSession; } } - } else if self.live.of_history(&session.id).is_some() { + } else if self.running_of_history(&session.id).is_some() { items.retain(|item| item.command != Command::SettleSession); } } diff --git a/crates/app/src/ui/shell.rs b/crates/app/src/ui/shell.rs index 5fc5684..421e953 100644 --- a/crates/app/src/ui/shell.rs +++ b/crates/app/src/ui/shell.rs @@ -2046,8 +2046,9 @@ impl Shell { } let from_row = || match &self.rows.get(self.cursor?)?.kind { Kind::Live(entry) => Some(entry.id), - // A history session with a terminal is that terminal's row. - Kind::Session(session) => self.live.of_history(&session.id).map(|live| live.id), + // A history session with a terminal or a conversation is that + // one's row. + Kind::Session(session) => self.running_of_history(&session.id), _ => None, }; let from_main = || match &self.main { @@ -2316,7 +2317,10 @@ impl Shell { // A session with a terminal is only brought forward; one // without starts its agent, so that asks first. // Reading its transcript starts nothing, so it never asks. - if self.live.of_history(&session.id).is_some() + if let Some(conversation) = self.chat_of_history(&session.id) { + // Its conversation is open: this row is that one's. + self.show_session(conversation, window, cx); + } else if self.live.of_history(&session.id).is_some() || crate::settings::text(cx, "history_open") == "transcript" { self.open_history(session.clone(), window, cx); diff --git a/crates/app/src/ui/terminals.rs b/crates/app/src/ui/terminals.rs index f8a9790..c65a2ec 100644 --- a/crates/app/src/ui/terminals.rs +++ b/crates/app/src/ui/terminals.rs @@ -1703,12 +1703,7 @@ impl Shell { // Waking it ends its sleep. self.slept.remove(&session.id); // One terminal per history session: look at the one that has it. - if let Some(running) = self - .live - .of_history(&session.id) - .map(|live| live.id) - .or_else(|| self.chat_of_history(&session.id)) - { + if let Some(running) = self.running_of_history(&session.id) { self.show_session(running, window, cx); return; } @@ -1765,7 +1760,7 @@ impl Shell { cx: &mut Context, ) { // Asked twice while it was being checked: it is running here now. - if let Some(running) = self.live.of_history(&session.id).map(|live| live.id) { + if let Some(running) = self.running_of_history(&session.id) { self.show_session(running, window, cx); return; } @@ -1843,7 +1838,7 @@ impl Shell { window: &mut Window, cx: &mut Context, ) { - if let Some(running) = self.live.of_history(&session.id).map(|live| live.id) { + if let Some(running) = self.running_of_history(&session.id) { self.show_session(running, window, cx); return; } @@ -1884,7 +1879,7 @@ impl Shell { ); return; } - if let Some(running) = self.live.of_history(&session.id).map(|live| live.id) { + if let Some(running) = self.running_of_history(&session.id) { self.show_session(running, window, cx); return; } @@ -2087,7 +2082,7 @@ impl Shell { match found { Target::Ready => { // Asked twice while it was being checked: it is running now. - if let Some(running) = self.live.of_history(&session.id).map(|live| live.id) { + if let Some(running) = self.running_of_history(&session.id) { self.show_session(running, window, cx); return; } @@ -2164,9 +2159,7 @@ impl Shell { }, Pane::Sidebar => match &self.rows.get(self.cursor?)?.kind { tree::Kind::Live(entry) => Some(entry.id), - tree::Kind::Session(session) => { - self.live.of_history(&session.id).map(|live| live.id) - } + tree::Kind::Session(session) => self.running_of_history(&session.id), _ => None, }, Pane::Files => None, diff --git a/crates/app/src/ui/tests_chat.rs b/crates/app/src/ui/tests_chat.rs index 5113570..896b2c8 100644 --- a/crates/app/src/ui/tests_chat.rs +++ b/crates/app/src/ui/tests_chat.rs @@ -447,6 +447,26 @@ fn a_conversation_and_the_session_its_agent_wrote_are_one_row(cx: &mut TestAppCo h.shows("tree-live-led-1", cx), "the row carries the conversation's light" ); + + // Looked away from and come back to by that row, it is the conversation + // again: nothing asks to resume what never stopped. + let row = h.row_of(NodeId::Session(session.clone()), cx).unwrap(); + cx.update_window(h.window.into(), |_, window, cx| { + h.shell.update(cx, |shell, cx| { + shell.main = Main::Empty; + shell.activate(row, window, cx); + }) + }) + .unwrap(); + h.settle(cx); + assert_eq!(h.main_kind(cx), "live:1"); + assert_eq!(h.shell(cx, |shell| shell.overlay), Overlay::None); + h.shell(cx, |shell| { + assert_eq!(shell.chats.len(), 1); + assert!(shell.live.all().is_empty(), "no terminal was started"); + // The keys that act on the session under the cursor find it too. + assert_eq!(shell.running_of_history(&session), Some(LiveId(1))); + }); } #[gpui_kit::test] From 5c4373fdc0a17e2a10beee0f622cea27b8b40df4 Mon Sep 17 00:00:00 2001 From: Victor Villlalobos Date: Tue, 13 Oct 2026 10:59:29 -0300 Subject: [PATCH 2/2] fix(chat): the chips name the model and the effort the agent runs with A conversation that asked for neither said Default for both. The agent is now asked what a turn would run with: Claude Code answers a second request for its settings as it weighs them, Codex's configuration names them or its list marks its own choice, Grok marks a model. What a turn says it answers with (claude-opus-5-5) is shown by the entry a person knows (Opus 5.5), and the lists say what Default is. --- CHANGELOG.md | 16 +++ crates/app/src/chat/complete.rs | 187 +++++++++++++++++++++++++--- crates/app/src/ui/chat_menu.rs | 11 +- crates/app/src/ui/chat_view.rs | 22 ++-- crates/leon-turn/src/claude.rs | 166 ++++++++++++++++++++++-- crates/leon-turn/src/codex.rs | 41 ++++++ crates/leon-turn/src/codex/tests.rs | 11 ++ crates/leon-turn/src/grok.rs | 5 + crates/leon-turn/src/grok/tests.rs | 2 + crates/leon-turn/src/opencode.rs | 1 + docs/ARCHITECTURE.md | 12 ++ 11 files changed, 436 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f98a52e..6c64c27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +### Fixed + +* A conversation in Leon's own interface names what its agent runs with. The + chips under the field said "Default" for the model and the effort until + you chose one; they now say the model and the effort the agent is set up + with (Claude Code is asked for its settings as it weighs them, Codex for + its configuration, Grok for the model it marks), and the lists say what + "Default" is. What a turn says it answers with (`claude-opus-5-5`) is + shown by the name you know it by (Opus 5.5). +* Coming back to a conversation by its row no longer asks to resume it. Once + the agent had written its session, the row was the history's, and a click + on it only looked for a terminal: it offered to start the agent again in + one. The row is the conversation's, as it is a terminal's, also for the + context menu, the keys that act on the session under the cursor and the + Den. + ## [0.10.0] - 2026-10-13 ### Added diff --git a/crates/app/src/chat/complete.rs b/crates/app/src/chat/complete.rs index d769067..9d75402 100644 --- a/crates/app/src/chat/complete.rs +++ b/crates/app/src/chat/complete.rs @@ -244,9 +244,14 @@ pub fn mode_rows(query: &str, options: &AgentOptions, current: Option<&str>) -> /// The agent's models that answer `query`, after the agent's own choice; /// `current` is the one the conversation asked for, if it asked. pub fn model_rows(query: &str, options: &AgentOptions, current: Option<&str>) -> Vec { + // What "default" is, where the agent said. + let own = model_shown(Some(options), None, None); let mut rows = vec![Row { label: "Default".to_owned(), - detail: Some("The one the agent is set up with on this computer.".to_owned()), + detail: Some(match own { + Some(own) => format!("{own}: the one the agent is set up with."), + None => "The one the agent is set up with.".to_owned(), + }), note: None, current: current.is_none(), warn: false, @@ -269,27 +274,83 @@ pub fn model_rows(query: &str, options: &AgentOptions, current: Option<&str>) -> rows } +/// The model of the agent's list that `name` is: the one with that id, or +/// one that stands for it (`opus` for `claude-opus-5-5`, which is what a +/// turn says it answers with). Among several that stand for it, the agent's +/// own choice, then one that is not "whatever is default". +pub fn model_named<'a>(options: &'a AgentOptions, name: &str) -> Option<&'a AgentModel> { + let by_id = options.models.iter().find(|model| model.id == name); + let stands_for = |model: &&AgentModel| model.resolves_to.as_deref() == Some(name); + by_id + .or_else(|| { + options + .models + .iter() + .filter(stands_for) + .find(|model| Some(&model.id) == options.default_model.as_ref()) + }) + .or_else(|| { + options + .models + .iter() + .filter(stands_for) + .find(|model| model.id != "default") + }) + .or_else(|| options.models.iter().find(stands_for)) +} + /// The model a turn would answer with: the one `asked` for, else the one the -/// agent says is `answering`, else the first it lists, which is its own -/// choice wherever it marks one. +/// agent says is `answering`, else the one it is set up with, else the first +/// it lists, which is its own choice wherever it marks one. pub fn model_in_use<'a>( options: &'a AgentOptions, asked: Option<&str>, answering: Option<&str>, ) -> Option<&'a AgentModel> { - let named = |id: Option<&str>| { - options - .models - .iter() - .find(|model| Some(model.id.as_str()) == id) - }; match asked { // One asked for that is not listed has no efforts anybody knows of. - Some(_) => named(asked), - None => named(answering).or(options.models.first()), + Some(asked) => options.models.iter().find(|model| model.id == asked), + None => answering + .and_then(|name| model_named(options, name)) + .or_else(|| { + options + .default_model + .as_deref() + .and_then(|name| model_named(options, name)) + }) + .or(options.models.first()), } } +/// The name of the model the next turn answers with, for whoever looks at +/// the conversation: the one asked for, else the one the last turn said it +/// answered with, else the one the agent is set up with. A model nobody +/// lists is named as it is written. `None` until anything is known. +pub fn model_shown( + options: Option<&AgentOptions>, + asked: Option<&str>, + answering: Option<&str>, +) -> Option { + let label = |name: &str| { + options + .and_then(|options| model_named(options, name)) + .map_or_else(|| name.to_owned(), |model| model.label.clone()) + }; + asked + .or(answering) + .or(options.and_then(|options| options.default_model.as_deref())) + .map(label) +} + +/// The effort the next turn thinks with: the one asked for, else the one the +/// agent is set up with. `None` when neither is known. +pub fn effort_shown<'a>( + options: Option<&'a AgentOptions>, + asked: Option<&'a str>, +) -> Option<&'a str> { + asked.or(options.and_then(|options| options.default_effort.as_deref())) +} + /// The efforts a turn can be asked for, the lightest first: those of /// [`model_in_use`], none when it has no such setting. pub fn efforts<'a>( @@ -331,10 +392,18 @@ fn effort_detail(effort: &str) -> Option<&'static str> { /// The efforts that answer `query`, the lightest first, after the agent's /// own choice; `current` is the one the conversation asked for, if it asked. -pub fn effort_rows(query: &str, efforts: &[String], current: Option<&str>) -> Vec { +pub fn effort_rows( + query: &str, + efforts: &[String], + current: Option<&str>, + own: Option<&str>, +) -> Vec { let mut rows = vec![Row { label: "Default".to_owned(), - detail: Some("As the agent is set up on this computer.".to_owned()), + detail: Some(match own { + Some(own) => format!("{}: as the agent is set up.", effort_label(own)), + None => "As the agent is set up.".to_owned(), + }), note: None, current: current.is_none(), warn: false, @@ -522,18 +591,102 @@ mod tests { label: "Opus".into(), description: None, efforts: vec!["low".into(), "high".into(), "xhigh".into()], + resolves_to: Some("claude-opus-5-5".into()), }, AgentModel { id: "anthropic/claude-haiku".into(), label: "claude-haiku · anthropic".into(), description: None, efforts: Vec::new(), + resolves_to: None, }, ], ..AgentOptions::default() } } + /// The same agent, set up to answer with its first model at high effort. + fn set_up() -> AgentOptions { + AgentOptions { + default_model: Some("opus".into()), + default_effort: Some("high".into()), + ..options() + } + } + + #[test] + fn what_is_shown_is_what_the_agent_runs_with_until_something_else_is_asked_for() { + let set_up = set_up(); + // Nothing asked for, no turn yet: the agent's own. + assert_eq!( + model_shown(Some(&set_up), None, None).as_deref(), + Some("Opus") + ); + assert_eq!(effort_shown(Some(&set_up), None), Some("high")); + // What a turn says it answers with is the model's own name: it is + // shown as the entry a person knows it by. + assert_eq!( + model_shown(Some(&set_up), None, Some("claude-opus-5-5")).as_deref(), + Some("Opus") + ); + // What is asked for here stands before both. + assert_eq!( + model_shown( + Some(&set_up), + Some("anthropic/claude-haiku"), + Some("claude-opus-5-5") + ) + .as_deref(), + Some("claude-haiku \u{b7} anthropic") + ); + assert_eq!(effort_shown(Some(&set_up), Some("low")), Some("low")); + // A model nobody lists is named as it is written. + assert_eq!( + model_shown(Some(&set_up), None, Some("my-model")).as_deref(), + Some("my-model") + ); + // An agent that said nothing of how it is set up: nothing to show. + assert_eq!(model_shown(Some(&options()), None, None), None); + assert_eq!(effort_shown(Some(&options()), None), None); + assert_eq!(model_shown(None, None, None), None); + } + + #[test] + fn the_efforts_offered_are_those_of_the_model_the_agent_runs_with() { + // Set up with a model that has none, nothing asked for. + let plain = AgentOptions { + default_model: Some("anthropic/claude-haiku".into()), + ..options() + }; + assert!(efforts(Some(&plain), None, None).is_empty()); + assert_eq!(efforts(Some(&set_up()), None, None).len(), 3); + // The model a turn answered with, by its own name. + assert_eq!( + model_in_use(&plain, None, Some("claude-opus-5-5")).map(|model| model.id.as_str()), + Some("opus") + ); + } + + #[test] + fn the_lists_say_what_default_is() { + let set_up = set_up(); + assert_eq!( + model_rows("", &set_up, None)[0].detail.as_deref(), + Some("Opus: the one the agent is set up with.") + ); + assert_eq!( + model_rows("", &options(), None)[0].detail.as_deref(), + Some("The one the agent is set up with.") + ); + let listed = efforts(Some(&set_up), None, None); + assert_eq!( + effort_rows("", listed, None, Some("high"))[0] + .detail + .as_deref(), + Some("High: as the agent is set up.") + ); + } + fn labels(rows: &[Row]) -> Vec<&str> { rows.iter().map(|row| row.label.as_str()).collect() } @@ -663,19 +816,19 @@ mod tests { assert!(of(Some("gone"), None).is_empty()); assert!(efforts(None, None, None).is_empty()); - let rows = effort_rows("", &of(None, None), Some("high")); + let rows = effort_rows("", &of(None, None), Some("high"), None); assert_eq!(labels(&rows), ["Default", "Low", "High", "Extra high"]); assert!(rows[2].current && !rows[0].current); assert_eq!(rows[0].pick, Pick::Effort(None)); assert_eq!(rows[3].pick, Pick::Effort(Some("xhigh".into()))); - assert!(effort_rows("", &of(None, None), None)[0].current); + assert!(effort_rows("", &of(None, None), None, None)[0].current); // By its words, or by the word the agent has for it. assert_eq!( - labels(&effort_rows("extra", &of(None, None), None)), + labels(&effort_rows("extra", &of(None, None), None, None)), ["Extra high"] ); assert_eq!( - labels(&effort_rows("xh", &of(None, None), None)), + labels(&effort_rows("xh", &of(None, None), None, None)), ["Extra high"] ); assert_eq!(effort_label("minimal"), "Minimal"); diff --git a/crates/app/src/ui/chat_menu.rs b/crates/app/src/ui/chat_menu.rs index 34b5f8c..9f0b1a4 100644 --- a/crates/app/src/ui/chat_menu.rs +++ b/crates/app/src/ui/chat_menu.rs @@ -123,9 +123,14 @@ impl Chat { MenuKind::Models => self.options.as_ref().map_or_else(Vec::new, |options| { complete::model_rows(&filter(), options, self.model.as_deref()) }), - MenuKind::Efforts => { - complete::effort_rows(&filter(), self.efforts(), self.effort.as_deref()) - } + MenuKind::Efforts => complete::effort_rows( + &filter(), + self.efforts(), + self.effort.as_deref(), + self.options + .as_ref() + .and_then(|options| options.default_effort.as_deref()), + ), MenuKind::Voices => complete::voice_rows(&filter(), voices), } } diff --git a/crates/app/src/ui/chat_view.rs b/crates/app/src/ui/chat_view.rs index 4ee1c53..5669f28 100644 --- a/crates/app/src/ui/chat_view.rs +++ b/crates/app/src/ui/chat_view.rs @@ -766,16 +766,14 @@ impl Shell { let current = chat.mode.as_ref().or(options.default_mode.as_ref())?; options.modes.iter().find(|mode| &mode.id == current) }); - let model = chat - .model - .as_ref() - .or(chat.conversation.model.as_ref()) - .map(|model| { - chat.options - .as_ref() - .and_then(|options| options.models.iter().find(|have| &have.id == model)) - .map_or_else(|| model.clone(), |found| found.label.clone()) - }); + // What the next turn runs with: what was asked for here, else what + // the agent itself is set up with. "Default" is only said while + // nothing is known of the agent yet. + let model = crate::chat::complete::model_shown( + chat.options.as_ref(), + chat.model.as_deref(), + chat.conversation.model.as_deref(), + ); let can_pick_mode = chat .options .as_ref() @@ -809,7 +807,9 @@ impl Shell { ); } if !chat.efforts().is_empty() { - let label = chat.effort.as_deref().map_or_else( + let effort = + crate::chat::complete::effort_shown(chat.options.as_ref(), chat.effort.as_deref()); + let label = effort.map_or_else( || "DEFAULT EFFORT".to_owned(), |effort| { format!( diff --git a/crates/leon-turn/src/claude.rs b/crates/leon-turn/src/claude.rs index 88a7fc7..d251e78 100644 --- a/crates/leon-turn/src/claude.rs +++ b/crates/leon-turn/src/claude.rs @@ -628,6 +628,10 @@ pub struct AgentModel { /// The efforts it can be asked for, the lightest first; none when it has /// no such setting. pub efforts: Vec, + /// The model this entry stands for, where the id is a name of the + /// agent's for it (`opus` for `claude-opus-5-5`): what a turn says it + /// answers with. + pub resolves_to: Option, } /// Something of the agent's own that a prompt can ask for by name: a @@ -673,6 +677,11 @@ pub struct AgentOptions { pub default_mode: Option, /// Its models. A turn started with none uses that computer's default. pub models: Vec, + /// The model a turn answers with when none is asked for, when known: an + /// id of [`Self::models`] where it is one of them. + pub default_model: Option, + /// The effort a turn thinks with when none is asked for, when known. + pub default_effort: Option, /// What a prompt can ask it for by name, as it lists them. pub commands: Vec, /// It asks before acting and waits for an answer. @@ -688,17 +697,24 @@ pub struct AgentOptions { } /// The command that asks Claude Code what it offers: its version, the modes -/// its help lists, and the answer to the opening request. `$1` is the -/// program and `$2` the request. -pub const OPTIONS_SCRIPT: &str = r#"printf 'leon-version:%s\n' "$("$1" --version 2>/dev/null | head -n 1)" -"$1" --help 2>/dev/null | tr -s ' \n' ' ' | sed -n 's/.*--permission-mode [^(]*(choices: \([^)]*\)).*/leon-modes:\1/p' +/// its help lists, and the answers to the requests it is given. `$1` is the +/// program and what follows are the requests, one a line. +pub const OPTIONS_SCRIPT: &str = r#"program=$1; shift +printf 'leon-version:%s\n' "$("$program" --version 2>/dev/null | head -n 1)" +"$program" --help 2>/dev/null | tr -s ' \n' ' ' | sed -n 's/.*--permission-mode [^(]*(choices: \([^)]*\)).*/leon-modes:\1/p' echo -printf '%s\n' "$2" | "$1" -p --input-format stream-json --output-format stream-json --verbose --no-session-persistence 2>/dev/null +printf '%s\n' "$@" | "$program" -p --input-format stream-json --output-format stream-json --verbose --no-session-persistence 2>/dev/null exit 0"#; -/// What [`OPTIONS_SCRIPT`] is given after the program: the opening request. +/// What [`OPTIONS_SCRIPT`] is given after the program: the opening request, +/// whose answer lists what it offers, and the request for its settings, +/// whose answer says what a turn would run with (`applied`: the model and +/// the effort, after every file and default was weighed). pub fn options_arguments() -> Vec { - vec![hello("leon-options")] + vec![ + hello("leon-options"), + control_request("leon-settings", json!({ "subtype": "get_settings" })), + ] } /// Reads the output of [`OPTIONS_SCRIPT`]. @@ -709,6 +725,8 @@ pub fn parse_options(text: &str) -> AgentOptions { live_changes: true, ..AgentOptions::default() }; + // The model a turn would run with, and the one the settings name. + let (mut applied, mut chosen) = (None::, None::); for line in text.lines() { let line = line.trim_end_matches('\r'); if let Some(version) = line.strip_prefix("leon-version:") { @@ -725,6 +743,19 @@ pub fn parse_options(text: &str) -> AgentOptions { continue; } let answer = &value["response"]["response"]; + // The answer about its settings: what a turn would run with. + if answer["applied"].is_object() { + applied = answer["applied"]["model"].as_str().map(str::to_owned); + chosen = answer["effective"]["model"].as_str().map(str::to_owned); + options.default_effort = answer["applied"]["effort"] + .as_str() + .filter(|effort| !effort.is_empty()) + .map(str::to_owned); + continue; + } + if !answer["models"].is_array() { + continue; + } options.default_mode = answer["current_permission_mode"] .as_str() .map(str::to_owned); @@ -743,6 +774,10 @@ pub fn parse_options(text: &str) -> AgentOptions { .iter() .map(text_of) .collect(), + resolves_to: model["resolvedModel"] + .as_str() + .filter(|name| !name.is_empty()) + .map(str::to_owned), }) }) .collect(); @@ -759,6 +794,27 @@ pub fn parse_options(text: &str) -> AgentOptions { .collect(); } } + options.default_model = applied.map(|applied| { + // The entry a person would know it by: the one their settings name, + // else the first that is that model and not "whatever is default". + let stands_for = |model: &&AgentModel| { + model.resolves_to.as_deref() == Some(applied.as_str()) || model.id == applied + }; + options + .models + .iter() + .filter(stands_for) + .find(|model| Some(&model.id) == chosen.as_ref()) + .or_else(|| { + options + .models + .iter() + .filter(stands_for) + .find(|model| model.id != "default") + }) + .or_else(|| options.models.iter().find(stands_for)) + .map_or(applied.clone(), |model| model.id.clone()) + }); if options.modes.is_empty() { options.assumed = true; options.modes = ["default", "acceptEdits", "plan", "bypassPermissions"] @@ -1415,6 +1471,102 @@ leon-modes:\"acceptEdits\", \"auto\", \"bypassPermissions\", \"manual\", \"dontA assert!(!options.assumed); } + /// What Claude Code 2.1.296 answered, cut to what is read: its models, + /// then its settings as it weighed them. + const OFFERED: &str = r#"{"type":"control_response","response":{"subtype":"success","request_id":"leon-options","response":{"models":[{"value":"default","resolvedModel":"claude-opus-5-5","displayName":"Default (recommended)","supportedEffortLevels":["low","medium","high"]},{"value":"opus","resolvedModel":"claude-opus-5-5","displayName":"Opus 5.5","supportedEffortLevels":["low","medium","high"]},{"value":"fable","resolvedModel":"claude-fable-5-1","displayName":"Fable 5.1","supportedEffortLevels":["low","medium","high"]}],"current_permission_mode":"auto"}}}"#; + + fn settings(effective: &str, applied: &str) -> String { + format!( + r#"{{"type":"control_response","response":{{"subtype":"success","request_id":"leon-settings","response":{{"effective":{effective},"sources":[],"applied":{applied}}}}}}}"# + ) + } + + #[test] + fn what_a_turn_would_run_with_is_asked_and_named_as_a_person_knows_it() { + // Both requests are written, each on its line. + let asked = options_arguments(); + assert_eq!(asked.len(), 2); + assert!(asked[0].contains(r#""subtype":"initialize""#)); + assert!(asked[1].contains(r#""subtype":"get_settings""#)); + assert!(asked.iter().all(|line| !line.contains('\n'))); + + // The settings name a model: that entry, not "whatever is default". + let text = format!( + "{OFFERED}\n{}\n", + settings( + r#"{"model":"opus"}"#, + r#"{"model":"claude-opus-5-5","effort":"medium"}"# + ) + ); + let options = parse_options(&text); + assert_eq!( + options.models.len(), + 3, + "the settings are not a list of models" + ); + assert_eq!(options.default_mode.as_deref(), Some("auto")); + assert_eq!(options.default_model.as_deref(), Some("opus")); + assert_eq!(options.default_effort.as_deref(), Some("medium")); + assert_eq!( + options.models[0].resolves_to.as_deref(), + Some("claude-opus-5-5") + ); + + // They name none: the entry that is that model. + let unnamed = format!( + "{OFFERED}\n{}\n", + settings("{}", r#"{"model":"claude-fable-5-1","effort":"high"}"#) + ); + let options = parse_options(&unnamed); + assert_eq!(options.default_model.as_deref(), Some("fable")); + assert_eq!(options.default_effort.as_deref(), Some("high")); + + // A model it does not list is still what it runs with. + let own = format!( + "{OFFERED}\n{}\n", + settings( + r#"{"model":"my-model"}"#, + r#"{"model":"my-model","effort":null}"# + ) + ); + let options = parse_options(&own); + assert_eq!(options.default_model.as_deref(), Some("my-model")); + assert_eq!(options.default_effort, None); + + // An older Claude Code that does not answer for its settings. + let options = parse_options(&format!("{OFFERED}\n")); + assert_eq!(options.default_model, None); + assert_eq!(options.default_effort, None); + } + + /// The script itself, with a stand-in that says what it was written. + #[cfg(unix)] + #[test] + fn the_script_writes_every_request_to_the_agent() { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempfile::tempdir().unwrap(); + let program = dir.path().join("claude"); + std::fs::write( + &program, + "#!/bin/sh\ncase \"$1\" in --version) echo '9.9 (Stand-in)';; --help) echo;; *) sed 's/^/got:/';; esac\n", + ) + .unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); + let output = std::process::Command::new("/bin/sh") + .args(["-c", OPTIONS_SCRIPT, "leon"]) + .arg(&program) + .args(options_arguments()) + .output() + .unwrap(); + let text = String::from_utf8_lossy(&output.stdout); + assert!(text.contains("leon-version:9.9 (Stand-in)"), "{text}"); + let got: Vec<&str> = text + .lines() + .filter_map(|line| line.strip_prefix("got:")) + .collect(); + assert_eq!(got, options_arguments()); + } + #[test] fn an_agent_that_does_not_answer_gets_the_modes_every_version_had() { let options = parse_options("command not found: claude\n"); diff --git a/crates/leon-turn/src/codex.rs b/crates/leon-turn/src/codex.rs index c90464c..2f2831c 100644 --- a/crates/leon-turn/src/codex.rs +++ b/crates/leon-turn/src/codex.rs @@ -1305,6 +1305,9 @@ pub fn parse_options(text: &str) -> AgentOptions { assumed: true, ..AgentOptions::default() }; + let (mut set_model, mut set_effort) = (None::, None::); + let mut listed_model = None::; + let mut efforts_unasked: Vec<(String, String)> = Vec::new(); for line in text.lines() { let line = line.trim_end_matches('\r'); if let Some(version) = line.strip_prefix("leon-version:") { @@ -1318,6 +1321,26 @@ pub fn parse_options(text: &str) -> AgentOptions { match value["id"].as_str() { Some("leon-models") if result["data"].is_array() => { options.assumed = false; + // Its own choice, and how hard each one thinks unasked. + let name = |model: &Value| { + model["model"] + .as_str() + .or(model["id"].as_str()) + .map(str::to_owned) + }; + listed_model = blocks(&result["data"]) + .iter() + .find(|model| model["isDefault"].as_bool() == Some(true)) + .and_then(name); + efforts_unasked = blocks(&result["data"]) + .iter() + .filter_map(|model| { + Some(( + name(model)?, + model["defaultReasoningEffort"].as_str()?.to_owned(), + )) + }) + .collect(); options.models = blocks(&result["data"]) .iter() .filter(|model| model["hidden"].as_bool() != Some(true)) @@ -1335,6 +1358,7 @@ pub fn parse_options(text: &str) -> AgentOptions { .map(|effort| text_of(&effort["reasoningEffort"])) .filter(|effort| !effort.is_empty()) .collect(), + resolves_to: None, }) }) .collect(); @@ -1367,10 +1391,27 @@ pub fn parse_options(text: &str) -> AgentOptions { options.default_mode = mode_named(&config["approval_policy"], &config["sandbox_mode"]) .map(str::to_owned); + let set = |value: &Value| { + value + .as_str() + .filter(|text| !text.is_empty()) + .map(str::to_owned) + }; + set_model = set(&config["model"]); + set_effort = set(&config["model_reasoning_effort"]); } _ => {} } } + // What its configuration names, else its own choice. + options.default_model = set_model.or(listed_model); + options.default_effort = set_effort.or_else(|| { + let model = options.default_model.as_ref()?; + efforts_unasked + .iter() + .find(|(id, _)| id == model) + .map(|(_, effort)| effort.clone()) + }); options } diff --git a/crates/leon-turn/src/codex/tests.rs b/crates/leon-turn/src/codex/tests.rs index 5e28954..a293ae8 100644 --- a/crates/leon-turn/src/codex/tests.rs +++ b/crates/leon-turn/src/codex/tests.rs @@ -967,6 +967,17 @@ fn what_it_offers_is_its_servers_models_and_the_modes_of_the_protocol() { ] ); assert_eq!(options.default_mode.as_deref(), Some("auto")); + // What its configuration names is what a turn runs with. + assert_eq!(options.default_model.as_deref(), Some("gpt-5.6-sol")); + assert_eq!(options.default_effort.as_deref(), Some("medium")); + // A configuration that names neither: its own choice, and how hard + // that one thinks unasked. + let unset = parse_options(&format!( + "{MODELS}\n{}\n", + r#"{"id":"leon-config","result":{"config":{"approval_policy":"on-request","sandbox_mode":"workspace-write"}}}"# + )); + assert_eq!(unset.default_model.as_deref(), Some("gpt-6.1-sol")); + assert_eq!(unset.default_effort.as_deref(), Some("low")); let models: Vec<(&str, &str, Vec<&str>)> = options .models .iter() diff --git a/crates/leon-turn/src/grok.rs b/crates/leon-turn/src/grok.rs index 05269e1..7d4dce7 100644 --- a/crates/leon-turn/src/grok.rs +++ b/crates/leon-turn/src/grok.rs @@ -118,11 +118,16 @@ pub fn parse_options(text: &str) -> AgentOptions { let Some(id) = rest.split_whitespace().next() else { continue; }; + // `(default)` marks the one it answers with when none is asked for. + if rest.contains("(default)") { + options.default_model = Some(id.to_owned()); + } options.models.push(AgentModel { id: id.to_owned(), label: id.to_owned(), description: None, efforts: EFFORTS.iter().map(|effort| (*effort).to_owned()).collect(), + resolves_to: None, }); } // Without its models nothing was heard from it: what is offered is a guess. diff --git a/crates/leon-turn/src/grok/tests.rs b/crates/leon-turn/src/grok/tests.rs index bd9482e..9621e27 100644 --- a/crates/leon-turn/src/grok/tests.rs +++ b/crates/leon-turn/src/grok/tests.rs @@ -304,6 +304,8 @@ fn its_models_are_read_from_its_own_list() { ["grok-4.7", "grok-4.7-build-fast", "grok-4.6", "grok-4.5"] ); assert!(options.models[0].efforts.contains(&"xhigh".to_owned())); + // The one it marks is the one it answers with unasked. + assert_eq!(options.default_model.as_deref(), Some("grok-4.7")); let modes: Vec<&str> = options.modes.iter().map(|mode| mode.id.as_str()).collect(); assert_eq!(modes, [MODE_ASK, acp::MODE_AUTO]); assert_eq!(options.default_mode.as_deref(), Some(MODE_ASK)); diff --git a/crates/leon-turn/src/opencode.rs b/crates/leon-turn/src/opencode.rs index 77d47af..7f2528c 100644 --- a/crates/leon-turn/src/opencode.rs +++ b/crates/leon-turn/src/opencode.rs @@ -130,6 +130,7 @@ pub fn parse_options(text: &str) -> AgentOptions { label: format!("{name} · {provider}"), description: None, efforts: EFFORTS.iter().map(|effort| (*effort).to_owned()).collect(), + resolves_to: None, }); } _ => {} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c9cdf56..1d44216 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1595,6 +1595,18 @@ While a list is open, `chat_key` gives it the arrows, `Ctrl+N`/`Ctrl+P`, where the person types. `leon-mobile` gets the same adapters and drops the commands at its boundary, which keeps its generated bindings as they were. +**What the agent runs with.** A conversation that asked for no model and no +effort still says which ones the next turn uses: `AgentOptions::default_model` +and `default_effort`, read when the agent is asked what it offers. Claude Code +answers a second request, `get_settings`, whose `applied` is the model and the +effort after every settings file and default was weighed; Codex's +configuration names them, or its list marks its own choice and how hard that +one thinks unasked; Grok marks a model in its list. `AgentModel::resolves_to` +is the model an entry stands for, so that what a turn says it answers with +(`claude-opus-5-5`) is shown as the entry a person knows (`complete::model_named`, +`model_shown`, `effort_shown`). A history row whose conversation is open is +that conversation's (`Shell::running_of_history`), as it is a terminal's. + **Which agents.** Claude Code, Codex, and the three that serve the Agent Client Protocol: opencode, Grok (`grok agent stdio`) and Cursor (`cursor-agent acp`). The last three share `leon_turn::acp`; a `Dialect` says