From 769202c05b860abf3911abe462dffeb51ed3df3b Mon Sep 17 00:00:00 2001
From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Date: Wed, 18 Mar 2026 12:56:59 -0400
Subject: [PATCH] feat(acp): Model discovery and selection for harness +
desktop (#97)
---
crates/sprout-acp/src/acp.rs | 322 +++++++++++++++++-
crates/sprout-acp/src/config.rs | 45 ++-
crates/sprout-acp/src/main.rs | 179 +++++++++-
crates/sprout-acp/src/pool.rs | 135 +++++++-
desktop/scripts/check-file-sizes.mjs | 2 +-
.../src-tauri/src/commands/agent_models.rs | 219 ++++++++++++
desktop/src-tauri/src/commands/agents.rs | 6 +
desktop/src-tauri/src/commands/mod.rs | 2 +
desktop/src-tauri/src/lib.rs | 2 +
.../src-tauri/src/managed_agents/runtime.rs | 6 +
desktop/src-tauri/src/managed_agents/types.rs | 50 +++
.../features/agents/ui/ManagedAgentCard.tsx | 4 +
.../agents/ui/ManagedAgentsSection.tsx | 1 +
.../src/features/agents/ui/ModelPicker.tsx | 173 ++++++++++
desktop/src/shared/api/tauri.ts | 21 ++
desktop/src/shared/api/types.ts | 25 ++
desktop/src/testing/e2eBridge.ts | 35 ++
17 files changed, 1207 insertions(+), 20 deletions(-)
create mode 100644 desktop/src-tauri/src/commands/agent_models.rs
create mode 100644 desktop/src/features/agents/ui/ModelPicker.tsx
diff --git a/crates/sprout-acp/src/acp.rs b/crates/sprout-acp/src/acp.rs
index 774b7abd4..a31106f5f 100644
--- a/crates/sprout-acp/src/acp.rs
+++ b/crates/sprout-acp/src/acp.rs
@@ -119,6 +119,16 @@ pub struct AcpClient {
impl AcpClient {
// ── Lifecycle ─────────────────────────────────────────────────────────
+ /// Kill the agent subprocess and wait for it to exit (no zombies).
+ ///
+ /// `Drop` only calls `start_kill()` (sends SIGKILL but doesn't reap).
+ /// Call this when you need guaranteed cleanup — e.g., in `run_models`
+ /// before process exit.
+ pub async fn shutdown(&mut self) {
+ let _ = self.child.start_kill();
+ let _ = self.child.wait().await;
+ }
+
/// Spawn the agent binary as a subprocess and connect to its stdio pipes.
///
/// After spawning, call [`initialize`](Self::initialize) before any other method.
@@ -172,14 +182,16 @@ impl AcpClient {
Ok(result)
}
- /// Send `session/new` and return the `sessionId` string.
+ /// Send `session/new` and return the full response alongside the session ID.
///
/// `cwd` must be an absolute path. `mcp_servers` may be empty.
- pub async fn session_new(
+ /// Callers use [`extract_model_config_options`] and [`extract_model_state`]
+ /// to pull model info from the raw result.
+ pub async fn session_new_full(
&mut self,
cwd: &str,
mcp_servers: Vec,
- ) -> Result {
+ ) -> Result {
let params = serde_json::json!({
"cwd": cwd,
"mcpServers": mcp_servers,
@@ -190,7 +202,50 @@ impl AcpClient {
.ok_or_else(|| AcpError::Protocol("session/new response missing sessionId".into()))?
.to_owned();
tracing::info!(target: "acp::session", "session created: {session_id}");
- Ok(session_id)
+ Ok(SessionNewResponse {
+ session_id,
+ raw: result,
+ })
+ }
+
+ /// Send `session/new` and return only the `sessionId` string.
+ ///
+ /// Convenience wrapper around [`session_new_full`].
+ #[allow(dead_code)] // Public API — callers outside the harness may use this.
+ pub async fn session_new(
+ &mut self,
+ cwd: &str,
+ mcp_servers: Vec,
+ ) -> Result {
+ Ok(self.session_new_full(cwd, mcp_servers).await?.session_id)
+ }
+
+ /// Send `session/set_config_option` (stable ACP path).
+ pub async fn session_set_config_option(
+ &mut self,
+ session_id: &str,
+ config_id: &str,
+ value: &str,
+ ) -> Result {
+ let params = serde_json::json!({
+ "sessionId": session_id,
+ "configId": config_id,
+ "value": value,
+ });
+ self.send_request("session/set_config_option", params).await
+ }
+
+ /// Send `session/set_model` (unstable ACP path).
+ pub async fn session_set_model(
+ &mut self,
+ session_id: &str,
+ model_id: &str,
+ ) -> Result {
+ let params = serde_json::json!({
+ "sessionId": session_id,
+ "modelId": model_id,
+ });
+ self.send_request("session/set_model", params).await
}
/// Send `session/prompt` and block until the agent returns a stop reason.
@@ -549,6 +604,99 @@ fn permission_response_cancelled(id: &serde_json::Value) -> serde_json::Value {
})
}
+// ─── Session response types ───────────────────────────────────────────────────
+
+/// Full `session/new` response — session ID plus the raw JSON result.
+///
+/// Callers use the extractor helpers to pull model info from `raw`.
+pub struct SessionNewResponse {
+ pub session_id: String,
+ /// The full `result` value from the JSON-RPC response.
+ pub raw: serde_json::Value,
+}
+
+/// How to switch to a particular model on a session.
+#[derive(Debug, Clone, PartialEq, serde::Serialize)]
+#[serde(tag = "type")]
+pub enum ModelSwitchMethod {
+ /// Stable: use `session/set_config_option` with these exact values.
+ ConfigOption {
+ config_id: String,
+ option_value: String,
+ },
+ /// Unstable: use `session/set_model` with this model_id.
+ SetModel { model_id: String },
+}
+
+/// Extract `configOptions` entries with `category == "model"` from a `session/new` result.
+///
+/// Returns the raw JSON array entries. Each entry has `configId`, `displayName`,
+/// `options: [{ value, displayName }]`, etc.
+pub fn extract_model_config_options(result: &serde_json::Value) -> Vec {
+ result["configOptions"]
+ .as_array()
+ .map(|arr| {
+ arr.iter()
+ .filter(|opt| opt.get("category").and_then(|c| c.as_str()) == Some("model"))
+ .cloned()
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+/// Extract `SessionModelState` (unstable path) from a `session/new` result.
+///
+/// Returns the `models` object if present: `{ currentModelId, availableModels: [...] }`.
+pub fn extract_model_state(result: &serde_json::Value) -> Option {
+ result.get("models").cloned()
+}
+
+/// Match a desired model ID against a fresh `session/new` response.
+///
+/// Returns the correct ACP method to call, or `None` if no match.
+///
+/// **Precedence**: stable `configOptions` first (spec-blessed), then unstable
+/// `availableModels`. The fresh `session/new` response is always authoritative.
+pub fn resolve_model_switch_method(
+ session_new_result: &serde_json::Value,
+ desired_model: &str,
+) -> Option {
+ // 1. Search stable configOptions for a "model"-category entry whose
+ // options contain a value matching desired_model.
+ for config_opt in extract_model_config_options(session_new_result) {
+ let config_id = match config_opt.get("configId").and_then(|v| v.as_str()) {
+ Some(id) => id,
+ None => continue,
+ };
+ if let Some(options) = config_opt.get("options").and_then(|v| v.as_array()) {
+ for opt in options {
+ if opt.get("value").and_then(|v| v.as_str()) == Some(desired_model) {
+ return Some(ModelSwitchMethod::ConfigOption {
+ config_id: config_id.to_string(),
+ option_value: desired_model.to_string(),
+ });
+ }
+ }
+ }
+ }
+
+ // 2. Search unstable availableModels for a matching modelId.
+ if let Some(models) = extract_model_state(session_new_result) {
+ if let Some(available) = models.get("availableModels").and_then(|v| v.as_array()) {
+ for model in available {
+ if model.get("modelId").and_then(|v| v.as_str()) == Some(desired_model) {
+ return Some(ModelSwitchMethod::SetModel {
+ model_id: desired_model.to_string(),
+ });
+ }
+ }
+ }
+ }
+
+ // 3. No match.
+ None
+}
+
// ─── Drop: kill child process ─────────────────────────────────────────────────
impl Drop for AcpClient {
@@ -880,4 +1028,170 @@ mod tests {
assert_eq!(cancelled_numeric["id"], numeric_id);
assert!(cancelled_numeric["id"].is_number());
}
+
+ // ── Model extractor tests ─────────────────────────────────────────────
+
+ #[test]
+ fn extract_model_config_options_finds_model_category() {
+ let result = serde_json::json!({
+ "sessionId": "sess-1",
+ "configOptions": [
+ {
+ "configId": "model",
+ "category": "model",
+ "displayName": "Model",
+ "options": [
+ { "value": "claude-sonnet-4-20250514", "displayName": "Claude Sonnet 4" },
+ { "value": "claude-opus-4-20250514", "displayName": "Claude Opus 4" }
+ ]
+ },
+ {
+ "configId": "theme",
+ "category": "appearance",
+ "displayName": "Theme",
+ "options": [{ "value": "dark", "displayName": "Dark" }]
+ }
+ ]
+ });
+ let opts = super::extract_model_config_options(&result);
+ assert_eq!(opts.len(), 1);
+ assert_eq!(opts[0]["configId"].as_str(), Some("model"));
+ }
+
+ #[test]
+ fn extract_model_config_options_empty_when_no_config_options() {
+ let result = serde_json::json!({ "sessionId": "sess-1" });
+ assert!(super::extract_model_config_options(&result).is_empty());
+ }
+
+ #[test]
+ fn extract_model_config_options_empty_when_no_model_category() {
+ let result = serde_json::json!({
+ "configOptions": [
+ { "configId": "theme", "category": "appearance" }
+ ]
+ });
+ assert!(super::extract_model_config_options(&result).is_empty());
+ }
+
+ #[test]
+ fn extract_model_state_returns_models_object() {
+ let result = serde_json::json!({
+ "sessionId": "sess-1",
+ "models": {
+ "currentModelId": "gpt-5",
+ "availableModels": [
+ { "modelId": "gpt-5", "name": "GPT-5" },
+ { "modelId": "o3-pro", "name": "o3 Pro" }
+ ]
+ }
+ });
+ let ms = super::extract_model_state(&result).expect("should have models");
+ assert_eq!(ms["currentModelId"].as_str(), Some("gpt-5"));
+ assert_eq!(ms["availableModels"].as_array().unwrap().len(), 2);
+ }
+
+ #[test]
+ fn extract_model_state_none_when_absent() {
+ let result = serde_json::json!({ "sessionId": "sess-1" });
+ assert!(super::extract_model_state(&result).is_none());
+ }
+
+ // ── resolve_model_switch_method tests ─────────────────────────────────
+
+ #[test]
+ fn resolve_prefers_stable_over_unstable() {
+ let result = serde_json::json!({
+ "configOptions": [{
+ "configId": "model",
+ "category": "model",
+ "options": [
+ { "value": "claude-sonnet-4-20250514", "displayName": "Sonnet 4" }
+ ]
+ }],
+ "models": {
+ "currentModelId": "claude-sonnet-4-20250514",
+ "availableModels": [
+ { "modelId": "claude-sonnet-4-20250514", "name": "Sonnet 4" }
+ ]
+ }
+ });
+ let method = super::resolve_model_switch_method(&result, "claude-sonnet-4-20250514");
+ assert_eq!(
+ method,
+ Some(super::ModelSwitchMethod::ConfigOption {
+ config_id: "model".to_string(),
+ option_value: "claude-sonnet-4-20250514".to_string(),
+ })
+ );
+ }
+
+ #[test]
+ fn resolve_falls_back_to_unstable() {
+ let result = serde_json::json!({
+ "models": {
+ "currentModelId": "gpt-5",
+ "availableModels": [
+ { "modelId": "gpt-5", "name": "GPT-5" },
+ { "modelId": "o3-pro", "name": "o3 Pro" }
+ ]
+ }
+ });
+ let method = super::resolve_model_switch_method(&result, "o3-pro");
+ assert_eq!(
+ method,
+ Some(super::ModelSwitchMethod::SetModel {
+ model_id: "o3-pro".to_string(),
+ })
+ );
+ }
+
+ #[test]
+ fn resolve_returns_none_when_no_match() {
+ let result = serde_json::json!({
+ "configOptions": [{
+ "configId": "model",
+ "category": "model",
+ "options": [{ "value": "claude-sonnet-4-20250514" }]
+ }],
+ "models": {
+ "availableModels": [{ "modelId": "gpt-5" }]
+ }
+ });
+ assert!(super::resolve_model_switch_method(&result, "nonexistent-model").is_none());
+ }
+
+ #[test]
+ fn resolve_returns_none_when_no_model_info() {
+ let result = serde_json::json!({ "sessionId": "sess-1" });
+ assert!(super::resolve_model_switch_method(&result, "anything").is_none());
+ }
+
+ #[test]
+ fn resolve_handles_multiple_config_options() {
+ // Agent could have multiple configOptions with category "model"
+ // (unlikely but defensive).
+ let result = serde_json::json!({
+ "configOptions": [
+ {
+ "configId": "primary-model",
+ "category": "model",
+ "options": [{ "value": "model-a" }]
+ },
+ {
+ "configId": "fallback-model",
+ "category": "model",
+ "options": [{ "value": "model-b" }]
+ }
+ ]
+ });
+ let method = super::resolve_model_switch_method(&result, "model-b");
+ assert_eq!(
+ method,
+ Some(super::ModelSwitchMethod::ConfigOption {
+ config_id: "fallback-model".to_string(),
+ option_value: "model-b".to_string(),
+ })
+ );
+ }
}
diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs
index d418ecc44..e088dc1d7 100644
--- a/crates/sprout-acp/src/config.rs
+++ b/crates/sprout-acp/src/config.rs
@@ -42,6 +42,37 @@ pub enum DedupMode {
Queue,
}
+// ── Models subcommand ─────────────────────────────────────────────────────────
+
+/// CLI args for `sprout-acp models` — query available models from an agent.
+///
+/// This is a standalone `Parser` (not a subcommand variant) because the
+/// `models` path must bypass `Config::from_cli()` entirely — no relay,
+/// no private key, no harness setup.
+#[derive(Debug, Parser)]
+#[command(
+ name = "sprout-acp models",
+ about = "Query available models from the configured agent"
+)]
+pub struct ModelsArgs {
+ /// Agent binary to spawn (e.g. "goose", "claude-agent-acp", "codex-acp").
+ #[arg(long, env = "SPROUT_ACP_AGENT_COMMAND", default_value = "goose")]
+ pub agent_command: String,
+
+ /// Arguments passed to the agent binary.
+ #[arg(
+ long,
+ env = "SPROUT_ACP_AGENT_ARGS",
+ default_value = "acp",
+ value_delimiter = ','
+ )]
+ pub agent_args: Vec,
+
+ /// Output structured JSON instead of human-readable text.
+ #[arg(long)]
+ pub json: bool,
+}
+
// ── CLI ───────────────────────────────────────────────────────────────────────
#[derive(Debug, Parser)]
@@ -161,6 +192,11 @@ pub struct CliArgs {
/// Disable typing indicators while agent is processing.
#[arg(long, env = "SPROUT_ACP_NO_TYPING")]
pub no_typing: bool,
+
+ /// Desired LLM model ID. Applied to every new ACP session after creation.
+ /// Use `sprout-acp models` to discover available model IDs.
+ #[arg(long, env = "SPROUT_ACP_MODEL")]
+ pub model: Option,
}
// ── Merged NIP-01 filter ──────────────────────────────────────────────────────
@@ -200,6 +236,8 @@ pub struct Config {
pub context_message_limit: u32,
pub presence_enabled: bool,
pub typing_enabled: bool,
+ /// Desired LLM model ID. Applied after every `session_new_full()`.
+ pub model: Option,
}
fn normalize_agent_command_identity(command: &str) -> String {
@@ -229,7 +267,7 @@ fn default_agent_args(command: &str) -> Option> {
}
}
-fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec {
+pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec {
let normalized = agent_args
.into_iter()
.map(|arg| arg.trim().to_string())
@@ -337,13 +375,14 @@ impl Config {
context_message_limit: args.context_message_limit,
presence_enabled: !args.no_presence,
typing_enabled: !args.no_typing,
+ model: args.model,
})
}
/// Human-readable summary (no secrets).
pub fn summary(&self) -> String {
format!(
- "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} timeout={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} ignore_self={} context_limit={} presence={} typing={}",
+ "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} timeout={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} ignore_self={} context_limit={} presence={} typing={} model={}",
self.relay_url,
self.keys.public_key().to_hex(),
self.agent_command,
@@ -358,6 +397,7 @@ impl Config {
self.context_message_limit,
self.presence_enabled,
self.typing_enabled,
+ self.model.as_deref().unwrap_or("(agent default)"),
)
}
}
@@ -654,6 +694,7 @@ mod tests {
context_message_limit: 12,
presence_enabled: true,
typing_enabled: true,
+ model: None,
}
}
diff --git a/crates/sprout-acp/src/main.rs b/crates/sprout-acp/src/main.rs
index 4d9bf7704..414a20a6e 100644
--- a/crates/sprout-acp/src/main.rs
+++ b/crates/sprout-acp/src/main.rs
@@ -13,7 +13,8 @@ use std::time::Duration;
use acp::{AcpClient, EnvVar, McpServer};
use anyhow::Result;
-use config::{Config, DedupMode, SubscribeMode};
+use clap::Parser;
+use config::{Config, DedupMode, ModelsArgs, SubscribeMode};
use filter::SubscriptionRule;
use futures_util::FutureExt;
use nostr::ToBech32;
@@ -28,8 +29,38 @@ use tokio::sync::watch;
use tracing_subscriber::EnvFilter;
use uuid::Uuid;
+// ── Subcommand dispatch ───────────────────────────────────────────────────────
+
+/// Check if argv[1] matches a subcommand name, before any clap parsing.
+///
+/// This avoids clap rejecting harness flags (like `--private-key`) that aren't
+/// declared on the subcommand's `Parser`. The `models` path has its own
+/// `ModelsArgs` parser; the default path uses the existing `CliArgs`.
+///
+/// **Constraint**: subcommand must be argv[1] — flags before the subcommand
+/// name (e.g., `sprout-acp --verbose models`) are not supported.
+fn is_subcommand(name: &str) -> bool {
+ std::env::args().nth(1).map(|a| a == name).unwrap_or(false)
+}
+
+/// Timeout for the `sprout-acp models` subcommand (spawn + init + session/new).
+const MODELS_TIMEOUT: Duration = Duration::from_secs(10);
+
#[tokio::main]
async fn main() -> Result<()> {
+ // ── Subcommand dispatch — before Config::from_cli() or any harness setup ──
+ if is_subcommand("models") {
+ // Strip the "models" token so clap doesn't reject it as a positional.
+ // Keeps argv[0] (binary name) and passes everything after "models".
+ let filtered: Vec = std::env::args()
+ .enumerate()
+ .filter(|(i, _)| *i != 1)
+ .map(|(_, a)| a)
+ .collect();
+ let args = ModelsArgs::parse_from(&filtered);
+ return run_models(args).await;
+ }
+
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("sprout_acp=info")),
@@ -49,6 +80,8 @@ async fn main() -> Result<()> {
acp,
sessions: HashMap::new(),
heartbeat_session: None,
+ model_capabilities: None,
+ desired_model: config.model.clone(),
});
}
tracing::info!("agent_pool_ready agents={}", agents.len());
@@ -790,6 +823,8 @@ async fn recover_panicked_agent(
acp,
sessions: HashMap::new(),
heartbeat_session: None,
+ model_capabilities: None,
+ desired_model: config.model.clone(),
});
tracing::info!("respawned agent {i} after panic");
}
@@ -900,6 +935,8 @@ async fn respawn_agent_into(old_agent: OwnedAgent, config: &Config) -> Result Result {
// ── build_mcp_servers ─────────────────────────────────────────────────────────
+// ── run_models ─────────────────────────────────────────────────────────────────
+
+/// `sprout-acp models` — spawn an agent, query its available models, exit.
+///
+/// Flow: spawn → initialize → session/new → print models → shutdown.
+/// No relay connection, no MCP servers, no subscriptions. ~2-5s total.
+async fn run_models(args: ModelsArgs) -> Result<()> {
+ use acp::{extract_model_config_options, extract_model_state};
+
+ let agent_args = config::normalize_agent_args(&args.agent_command, args.agent_args);
+ let cwd = std::env::current_dir()
+ .unwrap_or_else(|_| std::path::PathBuf::from("/"))
+ .to_string_lossy()
+ .to_string();
+
+ // Spawn outside the timeout so we always own the child for cleanup.
+ let mut client = match AcpClient::spawn(&args.agent_command, &agent_args).await {
+ Ok(c) => c,
+ Err(e) => {
+ eprintln!("error: failed to spawn agent: {e}");
+ std::process::exit(1);
+ }
+ };
+
+ // Initialize + session/new under a timeout. Client is owned above,
+ // so shutdown() runs on all paths (success, error, timeout).
+ let protocol_result = tokio::time::timeout(MODELS_TIMEOUT, async {
+ let init = client.initialize().await?;
+ let session = client.session_new_full(&cwd, vec![]).await?;
+ Ok::<_, acp::AcpError>((init, session))
+ })
+ .await;
+
+ let (init_result, session_resp) = match protocol_result {
+ Ok(Ok(tuple)) => tuple,
+ Ok(Err(e)) => {
+ client.shutdown().await;
+ eprintln!("error: agent communication failed: {e}");
+ std::process::exit(1);
+ }
+ Err(_) => {
+ client.shutdown().await;
+ eprintln!("error: agent timed out ({MODELS_TIMEOUT:?})");
+ std::process::exit(1);
+ }
+ };
+
+ // Extract agent info from initialize response.
+ // ACP spec uses "serverInfo" (MCP heritage); some agents may use "agentInfo".
+ let info_obj = init_result
+ .get("serverInfo")
+ .or_else(|| init_result.get("agentInfo"));
+ let agent_name = info_obj
+ .and_then(|ai| ai.get("name"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("unknown");
+ let agent_version = info_obj
+ .and_then(|ai| ai.get("version"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("unknown");
+
+ // Extract model info from session/new response.
+ let config_options = extract_model_config_options(&session_resp.raw);
+ let model_state = extract_model_state(&session_resp.raw);
+
+ if args.json {
+ // Structured JSON output — consumed by Phase 3 `get_agent_models`.
+ let output = serde_json::json!({
+ "agent": {
+ "name": agent_name,
+ "version": agent_version,
+ },
+ "stable": {
+ "configOptions": config_options,
+ },
+ "unstable": model_state.as_ref().map(|ms| serde_json::json!({
+ "currentModelId": ms.get("currentModelId"),
+ "availableModels": ms.get("availableModels"),
+ })),
+ });
+ println!("{}", serde_json::to_string_pretty(&output)?);
+ } else {
+ // Human-readable output.
+ println!("Agent: {} v{}", agent_name, agent_version);
+ println!();
+
+ let mut has_models = false;
+
+ if !config_options.is_empty() {
+ println!("Models (stable configOptions):");
+ for opt in &config_options {
+ let config_id = opt.get("configId").and_then(|v| v.as_str()).unwrap_or("?");
+ let display = opt
+ .get("displayName")
+ .and_then(|v| v.as_str())
+ .unwrap_or(config_id);
+ println!(" {display} (configId: {config_id})");
+ if let Some(options) = opt.get("options").and_then(|v| v.as_array()) {
+ for o in options {
+ let val = o.get("value").and_then(|v| v.as_str()).unwrap_or("?");
+ let name = o.get("displayName").and_then(|v| v.as_str()).unwrap_or(val);
+ println!(" - {name} (value: {val})");
+ }
+ }
+ }
+ has_models = true;
+ }
+
+ if let Some(ref ms) = model_state {
+ let current = ms
+ .get("currentModelId")
+ .and_then(|v| v.as_str())
+ .unwrap_or("(none)");
+ println!("Models (unstable SessionModelState):");
+ println!(" Current: {current}");
+ if let Some(available) = ms.get("availableModels").and_then(|v| v.as_array()) {
+ println!(" Available:");
+ for m in available {
+ let id = m.get("modelId").and_then(|v| v.as_str()).unwrap_or("?");
+ let name = m.get("name").and_then(|v| v.as_str()).unwrap_or(id);
+ let desc = m.get("description").and_then(|v| v.as_str()).unwrap_or("");
+ if desc.is_empty() {
+ println!(" - {name} (id: {id})");
+ } else {
+ println!(" - {name} (id: {id}) — {desc}");
+ }
+ }
+ }
+ has_models = true;
+ }
+
+ if !has_models {
+ println!("No model information available from this agent.");
+ }
+ }
+
+ client.shutdown().await;
+ Ok(())
+}
+
fn build_mcp_servers(config: &Config) -> Vec {
vec![McpServer {
name: "sprout-mcp".to_string(),
diff --git a/crates/sprout-acp/src/pool.rs b/crates/sprout-acp/src/pool.rs
index ffe4c5915..790c9ee44 100644
--- a/crates/sprout-acp/src/pool.rs
+++ b/crates/sprout-acp/src/pool.rs
@@ -28,7 +28,10 @@ use tokio::task::JoinSet;
use tokio::time::timeout;
use uuid::Uuid;
-use crate::acp::{AcpClient, AcpError, McpServer, StopReason};
+use crate::acp::{
+ extract_model_config_options, extract_model_state, resolve_model_switch_method, AcpClient,
+ AcpError, McpServer, ModelSwitchMethod, StopReason,
+};
use crate::config::DedupMode;
use crate::queue::{ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo};
use crate::relay::{ChannelInfo, RestClient};
@@ -47,6 +50,17 @@ pub struct TaskMeta {
pub recoverable_batch: Option,
}
+/// Agent-level model capabilities. Populated on first session creation.
+/// The catalog is the same across all sessions for a given agent process.
+/// Fields are read by the desktop's `get_agent_models` Tauri command (Phase 3).
+#[allow(dead_code)] // Scaffolding for desktop integration — fields read via serde.
+pub struct AgentModelCapabilities {
+ /// Stable: configOptions with category "model" from session/new.
+ pub config_options_raw: Vec,
+ /// Unstable: SessionModelState from session/new.
+ pub available_models_raw: Option,
+}
+
/// An agent with its session state, owned by the pool or a running task.
pub struct OwnedAgent {
pub index: usize,
@@ -54,6 +68,10 @@ pub struct OwnedAgent {
/// channel_id → session_id
pub sessions: HashMap,
pub heartbeat_session: Option,
+ /// Model catalog from first session/new. None until first session created.
+ pub model_capabilities: Option,
+ /// Desired model ID (from `Config.model`). Applied after every `session_new_full()`.
+ pub desired_model: Option,
}
/// Pool of agents with take-and-return ownership semantics.
@@ -243,6 +261,107 @@ impl AgentPool {
/// Timeout for pre-prompt context fetches (thread/DM history).
const CONTEXT_FETCH_TIMEOUT: Duration = Duration::from_millis(500);
+/// Timeout for model-switch requests (`session/set_config_option`, `session/set_model`).
+const MODEL_SWITCH_TIMEOUT: Duration = Duration::from_secs(5);
+
+/// Create a new ACP session via `session_new_full()`, populate model capabilities
+/// on the agent (first session only), and apply `desired_model` if set.
+///
+/// On error from `session_new_full()`, returns the `AcpError` — caller handles
+/// error reporting. Model-switch failures are logged and gracefully ignored
+/// (the agent proceeds with its default model).
+async fn create_session_and_apply_model(
+ agent: &mut OwnedAgent,
+ ctx: &PromptContext,
+) -> Result {
+ let resp = agent
+ .acp
+ .session_new_full(&ctx.cwd, ctx.mcp_servers.clone())
+ .await?;
+
+ // Populate model capabilities on first session creation.
+ if agent.model_capabilities.is_none() {
+ agent.model_capabilities = Some(AgentModelCapabilities {
+ config_options_raw: extract_model_config_options(&resp.raw),
+ available_models_raw: extract_model_state(&resp.raw),
+ });
+ }
+
+ // Apply desired_model if set, matching against the fresh session/new response.
+ if let Some(ref desired) = agent.desired_model {
+ match resolve_model_switch_method(&resp.raw, desired) {
+ Some(method) => {
+ apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await;
+ }
+ None => {
+ tracing::warn!(
+ target: "pool::model",
+ "desired model {desired} not found in agent's available models — proceeding with agent default"
+ );
+ }
+ }
+ }
+
+ Ok(resp.session_id)
+}
+
+/// Send the appropriate ACP model-switch request with a timeout.
+///
+/// On timeout or error, logs a warning and returns — the caller proceeds
+/// with the agent's default model. This is intentionally non-fatal: a stale
+/// response from a timed-out request is safely ignored by `read_until_response`
+/// (non-matching JSON-RPC IDs are skipped).
+async fn apply_model_switch(
+ acp: &mut AcpClient,
+ session_id: &str,
+ desired: &str,
+ method: &ModelSwitchMethod,
+) {
+ let method_label = match method {
+ ModelSwitchMethod::ConfigOption { config_id, .. } => {
+ format!("configOption (configId={config_id})")
+ }
+ ModelSwitchMethod::SetModel { .. } => "set_model".to_string(),
+ };
+
+ let result = tokio::time::timeout(MODEL_SWITCH_TIMEOUT, async {
+ match method {
+ ModelSwitchMethod::ConfigOption {
+ config_id,
+ option_value,
+ } => {
+ acp.session_set_config_option(session_id, config_id, option_value)
+ .await
+ }
+ ModelSwitchMethod::SetModel { model_id } => {
+ acp.session_set_model(session_id, model_id).await
+ }
+ }
+ })
+ .await;
+
+ match result {
+ Ok(Ok(_)) => {
+ tracing::info!(
+ target: "pool::model",
+ "applied model {desired} via {method_label} on session {session_id}"
+ );
+ }
+ Ok(Err(e)) => {
+ tracing::warn!(
+ target: "pool::model",
+ "failed to set model {desired} via {method_label}: {e} — proceeding with agent default"
+ );
+ }
+ Err(_) => {
+ tracing::warn!(
+ target: "pool::model",
+ "model set via {method_label} timed out ({MODEL_SWITCH_TIMEOUT:?}) — proceeding with agent default"
+ );
+ }
+ }
+}
+
/// Core async function spawned for each prompt.
///
/// Lifecycle:
@@ -285,12 +404,8 @@ pub async fn run_prompt_task(
if let Some(sid) = agent.sessions.get(cid) {
(sid.clone(), false)
} else {
- // Create new session.
- match agent
- .acp
- .session_new(&ctx.cwd, ctx.mcp_servers.clone())
- .await
- {
+ // Create new session with model application.
+ match create_session_and_apply_model(&mut agent, &ctx).await {
Ok(sid) => {
tracing::info!(
target: "pool::session",
@@ -326,11 +441,7 @@ pub async fn run_prompt_task(
if let Some(sid) = &agent.heartbeat_session {
(sid.clone(), false)
} else {
- match agent
- .acp
- .session_new(&ctx.cwd, ctx.mcp_servers.clone())
- .await
- {
+ match create_session_and_apply_model(&mut agent, &ctx).await {
Ok(sid) => {
tracing::info!(
target: "pool::session",
diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index 29c2798ac..4b95d48da 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -37,7 +37,7 @@ const overrides = new Map([
["src/features/sidebar/ui/AppSidebar.tsx", 650],
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
["src/shared/api/relayClientSession.ts", 725], // durable websocket session manager with reconnect/replay/recovery state
- ["src/shared/api/tauri.ts", 950],
+ ["src/shared/api/tauri.ts", 975],
]);
async function walkFiles(directory) {
diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs
new file mode 100644
index 000000000..97bddecaf
--- /dev/null
+++ b/desktop/src-tauri/src/commands/agent_models.rs
@@ -0,0 +1,219 @@
+use std::collections::HashSet;
+
+use tauri::{AppHandle, State};
+
+use crate::{
+ app_state::AppState,
+ managed_agents::{
+ build_managed_agent_summary, find_managed_agent_mut, load_managed_agents,
+ missing_command_message, resolve_command, save_managed_agents,
+ sync_managed_agent_processes, AgentModelInfo, AgentModelsResponse, ManagedAgentSummary,
+ UpdateManagedAgentRequest, DEFAULT_AGENT_ARG,
+ },
+ util::now_iso,
+};
+
+/// Query available models from an agent via `sprout-acp models --json`.
+///
+/// Spawns a short-lived subprocess (no relay connection needed). The subprocess
+/// starts the agent, queries its model catalog, and exits. ~2-5s total.
+#[tauri::command]
+pub async fn get_agent_models(
+ pubkey: String,
+ app: AppHandle,
+ state: State<'_, AppState>,
+) -> Result {
+ let (resolved_acp, agent_command, agent_args, persisted_model) = {
+ let _store_guard = state
+ .managed_agents_store_lock
+ .lock()
+ .map_err(|e| e.to_string())?;
+ let mut records = load_managed_agents(&app)?;
+ let mut runtimes = state
+ .managed_agent_processes
+ .lock()
+ .map_err(|e| e.to_string())?;
+ if sync_managed_agent_processes(&mut records, &mut runtimes) {
+ save_managed_agents(&app, &records)?;
+ }
+
+ let record = records
+ .iter()
+ .find(|r| r.pubkey == pubkey)
+ .ok_or_else(|| format!("agent {pubkey} not found"))?;
+
+ let resolved = resolve_command(&record.acp_command, Some(&app))
+ .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?;
+
+ let args = if record.agent_args.is_empty() {
+ vec![DEFAULT_AGENT_ARG.to_string()]
+ } else {
+ record.agent_args.clone()
+ };
+
+ (
+ resolved,
+ record.agent_command.clone(),
+ args,
+ record.model.clone(),
+ )
+ }; // store lock released — subprocess runs without holding the lock
+
+ // Use spawn_blocking because the desktop Tauri crate doesn't enable
+ // tokio's `process` feature. std::process::Command is synchronous
+ // but fine for a short-lived subprocess (~2-5s).
+ let output = tokio::task::spawn_blocking(move || {
+ std::process::Command::new(&resolved_acp)
+ .arg("models")
+ .arg("--json")
+ .env("SPROUT_ACP_AGENT_COMMAND", &agent_command)
+ .env("SPROUT_ACP_AGENT_ARGS", agent_args.join(","))
+ .env(
+ "GOOSE_MODE",
+ std::env::var("GOOSE_MODE").unwrap_or_else(|_| "auto".into()),
+ )
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped())
+ .output()
+ .map_err(|e| format!("failed to spawn sprout-acp models: {e}"))
+ })
+ .await
+ .map_err(|e| format!("model discovery task failed: {e}"))?
+ .map_err(|e: String| e)?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!(
+ "sprout-acp models failed (exit {}): {stderr}",
+ output.status.code().unwrap_or(-1)
+ ));
+ }
+
+ let raw: serde_json::Value = serde_json::from_slice(&output.stdout)
+ .map_err(|e| format!("failed to parse model JSON: {e}"))?;
+
+ Ok(normalize_agent_models(&raw, persisted_model))
+}
+
+/// Update mutable fields on an existing managed agent record.
+///
+/// Does NOT auto-restart the agent. The frontend should prompt the user
+/// to restart for model changes to take effect.
+#[tauri::command]
+pub fn update_managed_agent(
+ input: UpdateManagedAgentRequest,
+ app: AppHandle,
+ state: State<'_, AppState>,
+) -> Result {
+ let _store_guard = state
+ .managed_agents_store_lock
+ .lock()
+ .map_err(|e| e.to_string())?;
+ let mut records = load_managed_agents(&app)?;
+ let mut runtimes = state
+ .managed_agent_processes
+ .lock()
+ .map_err(|e| e.to_string())?;
+ sync_managed_agent_processes(&mut records, &mut runtimes);
+
+ let record = find_managed_agent_mut(&mut records, &input.pubkey)?;
+
+ // Tri-state: None = don't touch, Some(None) = clear, Some(Some(v)) = set
+ if let Some(model_update) = input.model {
+ record.model = model_update;
+ }
+ if let Some(prompt_update) = input.system_prompt {
+ record.system_prompt = prompt_update;
+ }
+ record.updated_at = now_iso();
+
+ save_managed_agents(&app, &records)?;
+
+ let record = records
+ .iter()
+ .find(|r| r.pubkey == input.pubkey)
+ .ok_or_else(|| format!("agent {} not found", input.pubkey))?;
+ build_managed_agent_summary(&app, record, &runtimes)
+}
+
+// ── Model normalization ───────────────────────────────────────────────────────
+
+/// Normalize raw `sprout-acp models --json` output into a typed DTO for the frontend.
+///
+/// Merges models from both ACP paths (stable configOptions + unstable SessionModelState),
+/// deduplicates by ID (stable takes precedence), and returns a unified list.
+fn normalize_agent_models(
+ raw: &serde_json::Value,
+ persisted_model: Option,
+) -> AgentModelsResponse {
+ let agent_name = raw["agent"]["name"]
+ .as_str()
+ .unwrap_or("unknown")
+ .to_string();
+ let agent_version = raw["agent"]["version"]
+ .as_str()
+ .unwrap_or("unknown")
+ .to_string();
+
+ let mut models: Vec = Vec::new();
+ let mut seen_ids: HashSet = HashSet::new();
+
+ // 1. Stable configOptions (preferred). Only entries with category "model"
+ // are model options — the CLI pre-filters, but we're defensive here.
+ if let Some(config_options) = raw["stable"]["configOptions"].as_array() {
+ for opt in config_options {
+ if opt.get("category").and_then(|c| c.as_str()) != Some("model") {
+ continue;
+ }
+ if let Some(options) = opt.get("options").and_then(|v| v.as_array()) {
+ for o in options {
+ if let Some(value) = o.get("value").and_then(|v| v.as_str()) {
+ if seen_ids.insert(value.to_string()) {
+ models.push(AgentModelInfo {
+ id: value.to_string(),
+ name: o
+ .get("displayName")
+ .and_then(|v| v.as_str())
+ .map(str::to_string),
+ description: None,
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // 2. Unstable availableModels (fallback — skip duplicates from stable).
+ let mut agent_default_model: Option = None;
+ if let Some(unstable) = raw.get("unstable") {
+ agent_default_model = unstable["currentModelId"].as_str().map(str::to_string);
+ if let Some(available) = unstable["availableModels"].as_array() {
+ for m in available {
+ if let Some(id) = m.get("modelId").and_then(|v| v.as_str()) {
+ if seen_ids.insert(id.to_string()) {
+ models.push(AgentModelInfo {
+ id: id.to_string(),
+ name: m.get("name").and_then(|v| v.as_str()).map(str::to_string),
+ description: m
+ .get("description")
+ .and_then(|v| v.as_str())
+ .map(str::to_string),
+ });
+ }
+ }
+ }
+ }
+ }
+
+ let supports_switching = !models.is_empty();
+
+ AgentModelsResponse {
+ agent_name,
+ agent_version,
+ models,
+ agent_default_model,
+ selected_model: persisted_model,
+ supports_switching,
+ }
+}
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index 5170d445a..0af094d8c 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -225,6 +225,12 @@ pub async fn create_managed_agent(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
+ model: input
+ .model
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(str::to_string),
start_on_app_launch: input.start_on_app_launch,
runtime_pid: None,
created_at: now_iso(),
diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index 45d3889d0..31b17e00e 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -1,3 +1,4 @@
+mod agent_models;
mod agent_settings;
mod agents;
mod channels;
@@ -7,6 +8,7 @@ mod messages;
mod profile;
mod tokens;
+pub use agent_models::*;
pub use agent_settings::*;
pub use agents::*;
pub use channels::*;
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 3ac8ed511..5c2784099 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -152,6 +152,8 @@ pub fn run() {
delete_managed_agent,
mint_managed_agent_token,
get_managed_agent_log,
+ get_agent_models,
+ update_managed_agent,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index ff110ea32..0bf69b3f0 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -173,6 +173,7 @@ pub fn build_managed_agent_summary(
turn_timeout_seconds: record.turn_timeout_seconds,
parallelism: record.parallelism,
system_prompt: record.system_prompt.clone(),
+ model: record.model.clone(),
has_api_token: record.api_token.is_some(),
status,
pid,
@@ -273,6 +274,11 @@ pub fn start_managed_agent_process(
} else {
command.env_remove("SPROUT_ACP_SYSTEM_PROMPT");
}
+ if let Some(model) = &record.model {
+ command.env("SPROUT_ACP_MODEL", model);
+ } else {
+ command.env_remove("SPROUT_ACP_MODEL");
+ }
command.env_remove("SPROUT_ACP_PRIVATE_KEY");
command.env_remove("SPROUT_ACP_API_TOKEN");
diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs
index ca2b9ffeb..8161beec2 100644
--- a/desktop/src-tauri/src/managed_agents/types.rs
+++ b/desktop/src-tauri/src/managed_agents/types.rs
@@ -27,6 +27,11 @@ pub struct ManagedAgentRecord {
#[serde(default = "default_agent_parallelism")]
pub parallelism: u32,
pub system_prompt: Option,
+ /// Desired LLM model ID. Matches AgentModelInfo.id from discovery.
+ /// The harness re-discovers the correct ACP switching metadata at session
+ /// creation by matching this ID against the fresh session/new response.
+ #[serde(default)]
+ pub model: Option,
#[serde(default = "default_start_on_app_launch")]
pub start_on_app_launch: bool,
#[serde(default)]
@@ -57,6 +62,7 @@ pub struct ManagedAgentSummary {
pub turn_timeout_seconds: u64,
pub parallelism: u32,
pub system_prompt: Option,
+ pub model: Option,
pub has_api_token: bool,
pub status: String,
pub pid: Option,
@@ -83,6 +89,7 @@ pub struct CreateManagedAgentRequest {
pub turn_timeout_seconds: Option,
pub parallelism: Option,
pub system_prompt: Option,
+ pub model: Option,
#[serde(default)]
pub mint_token: bool,
#[serde(default)]
@@ -164,6 +171,49 @@ pub struct SproutAdminMintTokenJsonOutput {
pub api_token: String,
}
+/// Patch request for updating a managed agent's mutable fields.
+///
+/// Tri-state nullable semantics via `Option
+
diff --git a/desktop/src/features/agents/ui/ManagedAgentsSection.tsx b/desktop/src/features/agents/ui/ManagedAgentsSection.tsx
index e0b1c9a92..b1bffafa3 100644
--- a/desktop/src/features/agents/ui/ManagedAgentsSection.tsx
+++ b/desktop/src/features/agents/ui/ManagedAgentsSection.tsx
@@ -110,6 +110,7 @@ export function ManagedAgentsSection({
onMintToken(pubkey, name);
}
}}
+ onModelChanged={onRefresh}
onSelect={onSelect}
onStart={(pubkey) => {
if (!isActionPending) {
diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx
new file mode 100644
index 000000000..a72fdc4b1
--- /dev/null
+++ b/desktop/src/features/agents/ui/ModelPicker.tsx
@@ -0,0 +1,173 @@
+import { ChevronDown, Loader2 } from "lucide-react";
+import React from "react";
+
+import type { AgentModelsResponse, ManagedAgent } from "@/shared/api/types";
+import { getAgentModels, updateManagedAgent } from "@/shared/api/tauri";
+import { Button } from "@/shared/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from "@/shared/ui/dropdown-menu";
+
+export function ModelPicker({
+ agent,
+ onModelChanged,
+}: {
+ agent: ManagedAgent;
+ onModelChanged?: () => void;
+}) {
+ const [modelsData, setModelsData] =
+ React.useState
(null);
+ const [loading, setLoading] = React.useState(false);
+ const [error, setError] = React.useState(null);
+ const [saving, setSaving] = React.useState(false);
+ const [needsRestart, setNeedsRestart] = React.useState(false);
+
+ const fetchModels = React.useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await getAgentModels(agent.pubkey);
+ setModelsData(data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setLoading(false);
+ }
+ }, [agent.pubkey]);
+
+ const currentValue = agent.model ?? modelsData?.agentDefaultModel ?? "";
+ const displayLabel =
+ agent.model ??
+ (modelsData?.agentDefaultModel
+ ? `${modelsData.agentDefaultModel} (default)`
+ : "Select model…");
+
+ const handleModelChange = async (modelId: string) => {
+ setSaving(true);
+ try {
+ await updateManagedAgent({
+ pubkey: agent.pubkey,
+ model: modelId === modelsData?.agentDefaultModel ? null : modelId,
+ });
+ if (agent.status === "running") {
+ setNeedsRestart(true);
+ }
+ onModelChanged?.();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ if (!modelsData && !loading && !error) {
+ return (
+
+
+ Model
+
+
+
+ );
+ }
+
+ if (loading) {
+ return (
+
+
+ Model
+
+
+
+ Discovering…
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ Model
+
+
{error}
+
+
+ );
+ }
+
+ if (!modelsData?.supportsSwitching) {
+ return (
+
+
+ Model
+
+
Not configurable
+
+ );
+ }
+
+ return (
+
+
+ Model
+
+
+
+
+
+ event.preventDefault()}
+ >
+
+ {modelsData.models.map((model) => (
+
+ {model.name ?? model.id}
+
+ ))}
+
+
+
+ {needsRestart ? (
+
+ Restart agent to apply
+
+ ) : null}
+
+ );
+}
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts
index 05561a4f9..56d605589 100644
--- a/desktop/src/shared/api/tauri.ts
+++ b/desktop/src/shared/api/tauri.ts
@@ -37,6 +37,8 @@ import type {
UsersBatchResponse,
CreateManagedAgentInput,
CreateManagedAgentResponse,
+ AgentModelsResponse,
+ UpdateManagedAgentInput,
AcpProvider,
CommandAvailability,
ManagedAgentPrereqs,
@@ -228,6 +230,7 @@ export type RawManagedAgent = {
turn_timeout_seconds: number;
parallelism: number;
system_prompt: string | null;
+ model: string | null;
has_api_token: boolean;
status: ManagedAgent["status"];
pid: number | null;
@@ -748,6 +751,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
turnTimeoutSeconds: agent.turn_timeout_seconds,
parallelism: agent.parallelism,
systemPrompt: agent.system_prompt,
+ model: agent.model,
hasApiToken: agent.has_api_token,
status: agent.status,
pid: agent.pid,
@@ -945,3 +949,20 @@ export async function discoverManagedAgentPrereqs(input: {
mcp: fromRawCommandAvailability(response.mcp),
};
}
+
+// ── Model discovery ───────────────────────────────────────────────────────────
+
+export async function getAgentModels(
+ pubkey: string,
+): Promise {
+ return invokeTauri("get_agent_models", { pubkey });
+}
+
+export async function updateManagedAgent(
+ input: UpdateManagedAgentInput,
+): Promise {
+ const response = await invokeTauri("update_managed_agent", {
+ input,
+ });
+ return fromRawManagedAgent(response);
+}
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts
index 17c24652f..06cc24f46 100644
--- a/desktop/src/shared/api/types.ts
+++ b/desktop/src/shared/api/types.ts
@@ -262,6 +262,7 @@ export type ManagedAgent = {
turnTimeoutSeconds: number;
parallelism: number;
systemPrompt: string | null;
+ model: string | null;
hasApiToken: boolean;
status: "running" | "stopped";
pid: number | null;
@@ -285,6 +286,7 @@ export type CreateManagedAgentInput = {
turnTimeoutSeconds?: number;
parallelism?: number;
systemPrompt?: string;
+ model?: string;
mintToken?: boolean;
tokenScopes?: TokenScope[];
tokenName?: string;
@@ -335,3 +337,26 @@ export type ManagedAgentPrereqs = {
acp: CommandAvailability;
mcp: CommandAvailability;
};
+
+// ── Model discovery types ─────────────────────────────────────────────────────
+
+export type AgentModelsResponse = {
+ agentName: string;
+ agentVersion: string;
+ models: AgentModelInfo[];
+ agentDefaultModel: string | null;
+ selectedModel: string | null;
+ supportsSwitching: boolean;
+};
+
+export type AgentModelInfo = {
+ id: string;
+ name: string | null;
+ description: string | null;
+};
+
+export type UpdateManagedAgentInput = {
+ pubkey: string;
+ model?: string | null;
+ systemPrompt?: string | null;
+};
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index cf6a50f15..bf0d69eb2 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -221,6 +221,7 @@ type RawManagedAgent = {
turn_timeout_seconds: number;
parallelism: number;
system_prompt: string | null;
+ model: string | null;
has_api_token: boolean;
status: "running" | "stopped";
pid: number | null;
@@ -491,6 +492,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent {
turn_timeout_seconds: agent.turn_timeout_seconds,
parallelism: agent.parallelism,
system_prompt: agent.system_prompt,
+ model: agent.model,
has_api_token: agent.has_api_token,
status: agent.status,
pid: agent.pid,
@@ -2140,6 +2142,7 @@ async function handleCreateManagedAgent(args: {
turnTimeoutSeconds?: number;
parallelism?: number;
systemPrompt?: string;
+ model?: string;
mintToken?: boolean;
tokenScopes?: string[];
tokenName?: string;
@@ -2172,6 +2175,7 @@ async function handleCreateManagedAgent(args: {
turn_timeout_seconds: args.input.turnTimeoutSeconds ?? 300,
parallelism: args.input.parallelism ?? 1,
system_prompt: args.input.systemPrompt?.trim() || null,
+ model: args.input.model?.trim() || null,
has_api_token: token !== null,
status: args.input.spawnAfterCreate ? "running" : "stopped",
pid: args.input.spawnAfterCreate ? 42000 + mockManagedAgents.length : null,
@@ -2301,6 +2305,24 @@ async function handleGetManagedAgentLog(args: {
};
}
+async function handleUpdateManagedAgent(args: {
+ input: {
+ pubkey: string;
+ model?: string | null;
+ systemPrompt?: string | null;
+ };
+}): Promise {
+ const agent = getMockManagedAgent(args.input.pubkey);
+ if (args.input.model !== undefined) {
+ agent.model = args.input.model;
+ }
+ if (args.input.systemPrompt !== undefined) {
+ agent.system_prompt = args.input.systemPrompt;
+ }
+ agent.updated_at = new Date().toISOString();
+ return cloneManagedAgent(agent);
+}
+
async function handleSearchMessages(
args: {
q: string;
@@ -2880,6 +2902,19 @@ export function maybeInstallE2eTauriMocks() {
return handleGetManagedAgentLog(
payload as Parameters[0],
);
+ case "get_agent_models":
+ return {
+ agentName: "mock-agent",
+ agentVersion: "0.0.0",
+ models: [],
+ agentDefaultModel: null,
+ selectedModel: null,
+ supportsSwitching: false,
+ };
+ case "update_managed_agent":
+ return handleUpdateManagedAgent(
+ payload as Parameters[0],
+ );
case "create_channel":
return handleCreateChannel(
payload as Parameters[0],