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}`}