From e59ea56e0fef3a10110a40105401b89479a7f3fc Mon Sep 17 00:00:00 2001 From: Quintin Botes Date: Mon, 3 Aug 2026 02:16:54 +0200 Subject: [PATCH] Choose a model and a reasoning effort before a Thread starts `LaunchConfig.model` existed and already emitted `--model`. Nothing ever set it, so the plumbing was there and unreachable: every Thread ran whatever the CLI happened to default to, and the one thing that most changes what a Thread costs was the one thing the composer could not express. `--effort` is now confirmed present on the shipped 2.1.220 binary, taking `low`, `medium`, `high`, `xhigh` and `max`. It had been assumed to be a slash command only. `LaunchConfig` carries it alongside the model and the adapter emits it. The options are declared by the adapter, not listed in the UI. This is the rule the mode picker already follows: a control offering a choice the runtime would reject is worse than one offering none. `AgentRuntime::launch_options()` returns empty by default, so a runtime that takes neither draws no controls, and Codex does not get a picker full of Claude aliases. Models are aliases rather than pinned identifiers, because `claude --help` documents them as tracking whatever is current. A pinned `claude-opus-4-1` would rot in the worst possible way: the old name still resolves, so it would fail by quietly running last year's model rather than by erroring. The effort list, by contrast, has to be exact, and a test pins it. An unrecognised `--effort` value is only a warning: the CLI prints one line, falls back to the default and runs anyway. A typo there produces a session that appears to run at the requested effort and does not, which is the exact class of silent mismatch this project exists to make visible. Both are launch flags, so the pickers appear only while nothing is running. The alias and the model it resolved to are shown together once the session reports back, since they differ and the difference is the cost. Two smaller things follow from the same reasoning. An empty selection means "whatever the profile already chooses" and is never sent as an empty flag value. And switching profile clears both, because a profile can switch runtime and an alias one runtime resolves is one another may reject or, worse, misread. `launch_options` rides on `agents_overview` rather than `agents_discovery`: the composer draws these before anything is probed, and every adapter declares them statically, so it costs a lock and a map. rust 688 to 691, vitest 324 to 328. Co-Authored-By: Claude Opus 5 --- crates/agent-runtime/src/claude/mod.rs | 110 ++++++++++++++++++++++++ crates/agent-runtime/src/lib.rs | 5 +- crates/agent-runtime/src/runtime.rs | 50 +++++++++++ crates/tervin-app/src/commands.rs | 27 +++++- ui/src/components/ThreadPanel.tsx | 80 ++++++++++++++++- ui/src/components/surfaces.dom.test.tsx | 69 +++++++++++++++ ui/src/lib/agents.store.test.ts | 26 ++++++ ui/src/lib/api.ts | 22 +++++ ui/src/lib/store.ts | 14 ++- 9 files changed, 398 insertions(+), 5 deletions(-) diff --git a/crates/agent-runtime/src/claude/mod.rs b/crates/agent-runtime/src/claude/mod.rs index bd20eb5..0a6e5c4 100644 --- a/crates/agent-runtime/src/claude/mod.rs +++ b/crates/agent-runtime/src/claude/mod.rs @@ -85,6 +85,48 @@ pub fn permission_modes() -> Vec { ] } +/// The models offered, as aliases rather than pinned identifiers. +/// +/// `claude --help` documents these as "an alias for the latest model", so the CLI +/// resolves each to whatever is current. Pinning `claude-opus-4-1-20250805` here +/// would mean shipping a list that silently rots: every new model would need a +/// Tervin release, and worse, a stale entry names a model that still exists and so +/// fails by quietly running the wrong one rather than by erroring. +/// +/// The resolved name is reported back by the session and shown alongside, because +/// the alias is not what runs and the difference is what costs money. +pub fn model_choices() -> Vec { + use crate::runtime::LaunchChoice; + vec![ + LaunchChoice::new("", "Profile default") + .with_note("Whatever the profile or the CLI's own configuration selects."), + LaunchChoice::new("opus", "Opus").with_note("Most capable, and the most expensive."), + LaunchChoice::new("sonnet", "Sonnet").with_note("The general-purpose balance."), + LaunchChoice::new("fable", "Fable"), + LaunchChoice::new("haiku", "Haiku").with_note("Fastest and cheapest."), + ] +} + +/// The reasoning-effort levels the CLI accepts. +/// +/// Unlike the models, this list has to be exact. An unrecognised `--effort` value +/// is a *warning*, not an error: the CLI prints one line, falls back to the default +/// and runs anyway. A typo would therefore produce a session that looks like it is +/// running at the requested effort and is not, which is precisely the class of +/// silent mismatch Tervin exists to make visible. These five are the values the +/// shipped binary names when it rejects one. +pub fn effort_choices() -> Vec { + use crate::runtime::LaunchChoice; + vec![ + LaunchChoice::new("", "Default effort"), + LaunchChoice::new("low", "Low").with_note("Least thinking, least cost."), + LaunchChoice::new("medium", "Medium"), + LaunchChoice::new("high", "High"), + LaunchChoice::new("xhigh", "Extra high"), + LaunchChoice::new("max", "Max").with_note("Most thinking, and the slowest."), + ] +} + /// Shared state between the session handle and its reader task. struct Shared { normalizer: Mutex, @@ -300,6 +342,10 @@ impl ClaudeCodeRuntime { args.push("--model".into()); args.push(model.clone()); } + if let Some(effort) = &config.effort { + args.push("--effort".into()); + args.push(effort.clone()); + } let mode = config .permission_mode .clone() @@ -542,6 +588,13 @@ impl AgentRuntime for ClaudeCodeRuntime { Self::static_capabilities() } + fn launch_options(&self) -> crate::runtime::LaunchOptions { + crate::runtime::LaunchOptions { + models: model_choices(), + efforts: effort_choices(), + } + } + async fn launch(&self, config: LaunchConfig) -> Result { self.start(config, None).await } @@ -1077,6 +1130,63 @@ mod tests { assert_eq!(args[i + 1], "abc-123"); } + #[test] + fn model_and_effort_are_passed_only_when_chosen() { + let rt = ClaudeCodeRuntime::new(); + + // Nothing chosen: neither flag appears, so the CLI's own configuration and + // the user's defaults decide. Passing an empty value would override them. + let bare = rt.build_args(&config(), None, None); + assert!(!bare.iter().any(|a| a == "--model")); + assert!(!bare.iter().any(|a| a == "--effort")); + + let mut cfg = config(); + cfg.model = Some("opus".into()); + cfg.effort = Some("high".into()); + let args = rt.build_args(&cfg, None, None); + let m = args + .iter() + .position(|a| a == "--model") + .expect("no --model"); + assert_eq!(args[m + 1], "opus"); + let e = args + .iter() + .position(|a| a == "--effort") + .expect("no --effort"); + assert_eq!(args[e + 1], "high"); + } + + #[test] + fn the_offered_efforts_are_exactly_the_ones_the_cli_accepts() { + // This list has to be exact in a way the model list does not. An + // unrecognised `--effort` value is a warning, not an error: the CLI falls + // back to the default and runs anyway, so a wrong entry here produces a + // session that reports one effort and spends another. These five are what + // the binary names when it rejects a value. + let offered: Vec = effort_choices() + .into_iter() + .map(|c| c.value) + .filter(|v| !v.is_empty()) + .collect(); + assert_eq!(offered, ["low", "medium", "high", "xhigh", "max"]); + } + + #[test] + fn the_offered_models_are_aliases_rather_than_pinned_identifiers() { + // A pinned id fails by quietly running last year's model, since the old name + // still resolves. An alias is documented to track whatever is current, so + // the list cannot rot into silently wrong. + for choice in model_choices() { + assert!( + !choice.value.starts_with("claude-"), + "{} is a pinned identifier, not an alias", + choice.value + ); + } + let values: Vec = model_choices().into_iter().map(|c| c.value).collect(); + assert!(values.contains(&String::new()), "no way to express 'unset'"); + } + #[test] fn policy_is_pushed_down_to_the_runtime() { // Rules must apply before an action runs, not only after Tervin sees it. diff --git a/crates/agent-runtime/src/lib.rs b/crates/agent-runtime/src/lib.rs index 7b16a8f..8f36b10 100644 --- a/crates/agent-runtime/src/lib.rs +++ b/crates/agent-runtime/src/lib.rs @@ -45,8 +45,9 @@ pub use mcp::{McpConfig, McpServer}; pub use profile::{AgentProfile, ImportCandidate, ProfileConfig}; pub use registry::{RuntimeRegistry, GENERIC_AGENTS}; pub use runtime::{ - AgentRuntime, AgentSession, ArbiterDecision, Attachment, Discovery, LaunchConfig, - LaunchedSession, PermissionArbiter, PermissionState, RuntimeError, SessionMetadata, + AgentRuntime, AgentSession, ArbiterDecision, Attachment, Discovery, LaunchChoice, LaunchConfig, + LaunchOptions, LaunchedSession, PermissionArbiter, PermissionState, RuntimeError, + SessionMetadata, }; /// Resolve a binary on `PATH`. diff --git a/crates/agent-runtime/src/runtime.rs b/crates/agent-runtime/src/runtime.rs index 3eca08d..f7cd6b7 100644 --- a/crates/agent-runtime/src/runtime.rs +++ b/crates/agent-runtime/src/runtime.rs @@ -130,6 +130,8 @@ pub struct LaunchConfig { pub attachments: Vec, /// Runtime-specific model selector, when the runtime supports choosing. pub model: Option, + /// Runtime-specific reasoning effort, when the runtime supports choosing. + pub effort: Option, /// Runtime-specific permission mode. pub permission_mode: Option, /// Tool patterns Tervin Rules pre-authorises, passed to the runtime so policy @@ -172,6 +174,7 @@ impl LaunchConfig { prompt: None, attachments: Vec::new(), model: None, + effort: None, permission_mode: None, allowed_tools: Vec::new(), disallowed_tools: Vec::new(), @@ -324,6 +327,19 @@ pub trait AgentRuntime: Send + Sync { /// Static capability declaration, refined by `discover` and by a live session. fn capabilities(&self) -> Capabilities; + /// The launch choices this runtime accepts, for controls shown *before* a + /// session exists. + /// + /// Separate from the modes a live session reports, because the composer has to + /// offer these when there is nothing running to ask. Declared by the adapter + /// rather than listed in the UI for the same reason the mode picker is: an + /// interface offering a choice the runtime would reject is worse than one + /// offering none. A runtime that takes neither returns the default and the + /// controls do not appear at all. + fn launch_options(&self) -> LaunchOptions { + LaunchOptions::default() + } + /// Start a new session. async fn launch(&self, config: LaunchConfig) -> Result; @@ -331,6 +347,40 @@ pub trait AgentRuntime: Send + Sync { async fn resume(&self, resume_id: &str, config: LaunchConfig) -> Result; } +/// One option in a launch control, as the adapter defines it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LaunchChoice { + /// What is passed to the runtime verbatim. + pub value: String, + /// What the picker shows. + pub label: String, + /// A caveat worth reading before choosing, such as what it costs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +impl LaunchChoice { + pub fn new(value: impl Into, label: impl Into) -> Self { + Self { + value: value.into(), + label: label.into(), + note: None, + } + } + + pub fn with_note(mut self, note: impl Into) -> Self { + self.note = Some(note.into()); + self + } +} + +/// What a runtime accepts at launch. Empty means the control is not shown. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LaunchOptions { + pub models: Vec, + pub efforts: Vec, +} + /// A session plus the event stream it produces. /// /// The receiver is the `event_stream()` of the specified interface. It is handed diff --git a/crates/tervin-app/src/commands.rs b/crates/tervin-app/src/commands.rs index c8300a0..fdea1c8 100644 --- a/crates/tervin-app/src/commands.rs +++ b/crates/tervin-app/src/commands.rs @@ -795,6 +795,13 @@ pub async fn audit_recent( pub struct AgentsOverview { pub profiles: Vec, pub default_profile: Option, + /// What each runtime accepts at launch, keyed by runtime id. + /// + /// Carried here rather than with discovery because the composer draws these + /// controls before anything has been probed, and a control that only appears + /// once a subprocess finishes is one the user will not find. It is also free: + /// every adapter declares this statically. + pub launch_options: std::collections::BTreeMap, /// Where the files the UI mentions actually are. /// /// Resolved rather than written into the interface, because the location differs by @@ -816,9 +823,19 @@ pub struct AgentsDiscovery { #[tauri::command] pub async fn agents_overview(state: State<'_, Arc>) -> Result { let profiles = state.profiles.read().clone(); + // Static declarations, so this stays a lock and a map with nothing spawned. + let launch_options = state + .agents + .read() + .snapshot() + .into_iter() + .map(|a| (a.runtime_id().to_string(), a.launch_options())) + .collect(); + Ok(AgentsOverview { default_profile: profiles.default_profile, profiles: profiles.profiles, + launch_options, profiles_path: tervin_core::paths::abbreviate(&ProfileConfig::path()), mcp_path: tervin_core::paths::abbreviate(&agent_runtime::McpConfig::path()), }) @@ -1063,6 +1080,8 @@ pub struct ThreadStartRequest { #[serde(default)] pub attachments: Vec, pub model: Option, + /// Reasoning effort, where the runtime offers it. + pub effort: Option, pub permission_mode: Option, pub task_title: Option, /// Resume a previous session by its runtime-issued id. @@ -1111,7 +1130,13 @@ pub async fn thread_start( let mut config = LaunchConfig::new(thread_id.clone(), cwd.clone()); config.prompt = Some(request.prompt.clone()); config.attachments = request.attachments; - config.model = request.model.or_else(|| profile.model.clone()); + // An empty selection means "whatever the profile or the CLI already chooses", + // which is not the same as passing an empty flag value. + config.model = request + .model + .filter(|m| !m.trim().is_empty()) + .or_else(|| profile.model.clone()); + config.effort = request.effort.filter(|e| !e.trim().is_empty()); config.permission_mode = request .permission_mode .or_else(|| profile.permission_mode.clone()); diff --git a/ui/src/components/ThreadPanel.tsx b/ui/src/components/ThreadPanel.tsx index 65d42f7..ac10cbf 100644 --- a/ui/src/components/ThreadPanel.tsx +++ b/ui/src/components/ThreadPanel.tsx @@ -94,6 +94,15 @@ export function ThreadPanel() { const profiles = s.agents?.profiles ?? []; const profile = profiles.find((p) => p.id === s.activeProfileId) ?? profiles[0]; + // What was asked for and what is actually running. They differ whenever an alias + // was used, which is most of the time, and the difference is what it costs. + const resolvedModel = thread?.info?.metadata.model ?? null; + const requestedModel = s.activeModel; + const modelLine = + resolvedModel && requestedModel && resolvedModel !== requestedModel + ? `${requestedModel} → ${resolvedModel}` + : (resolvedModel ?? (requestedModel || null)); + // Poll live session facts while a Thread is working. Metadata such as cost and // MCP state is push-free on the runtime side, so it is pulled at a low rate // rather than on every event. @@ -178,6 +187,8 @@ export function ThreadPanel() { profile_id: profile?.id ?? null, prompt: text, attachments, + model: s.activeModel || null, + effort: s.activeEffort || null, task_title: text.slice(0, 80), }); s.clearAttachments(); @@ -322,6 +333,11 @@ export function ThreadPanel() { )} + {/* Model and effort, offered only where the runtime declares them and only + before a session exists: both are launch flags, so changing one after a + Thread is running would claim an effect it cannot have. */} + {!thread?.info?.running && } + {/* Modes as the running session reported them. Never a hard-coded list: Claude Code offers four, an ACP agent offers whatever it defines, and a control offering a mode the agent would reject is worse than none. */} @@ -518,8 +534,12 @@ export function ThreadPanel() { )}
- + {profile ? `${profile.name} · ${profile.runtime_id}` : "No agent profile configured"} + {/* An alias is not what runs. `opus` resolves to whichever model is + current, and which one that is decides what the Thread costs, so + once the session says what it actually got, that is shown too. */} + {modelLine && ` · ${modelLine}`}