feat(acp): Model discovery and selection for harness + desktop (#97)

This commit is contained in:
tlongwell-block
2026-03-18 09:56:59 -07:00
committed by GitHub
parent 60d1d2334d
commit 769202c05b
17 changed files with 1207 additions and 20 deletions
+318 -4
View File
@@ -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<McpServer>,
) -> Result<String, AcpError> {
) -> Result<SessionNewResponse, AcpError> {
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<McpServer>,
) -> Result<String, AcpError> {
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<serde_json::Value, AcpError> {
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<serde_json::Value, AcpError> {
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<serde_json::Value> {
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<serde_json::Value> {
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<ModelSwitchMethod> {
// 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(),
})
);
}
}
+43 -2
View File
@@ -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<String>,
/// 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<String>,
}
// ── 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<String>,
}
fn normalize_agent_command_identity(command: &str) -> String {
@@ -229,7 +267,7 @@ fn default_agent_args(command: &str) -> Option<Vec<String>> {
}
}
fn normalize_agent_args(command: &str, agent_args: Vec<String>) -> Vec<String> {
pub fn normalize_agent_args(command: &str, agent_args: Vec<String>) -> Vec<String> {
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,
}
}
+178 -1
View File
@@ -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<String> = 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<Ow
acp,
sessions: HashMap::new(),
heartbeat_session: None,
model_capabilities: None,
desired_model: config.model.clone(),
})
}
@@ -921,6 +958,146 @@ async fn spawn_and_init(config: &Config) -> Result<AcpClient> {
// ── 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<McpServer> {
vec![McpServer {
name: "sprout-mcp".to_string(),
+123 -12
View File
@@ -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<FlushBatch>,
}
/// 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<serde_json::Value>,
/// Unstable: SessionModelState from session/new.
pub available_models_raw: Option<serde_json::Value>,
}
/// 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<Uuid, String>,
pub heartbeat_session: Option<String>,
/// Model catalog from first session/new. None until first session created.
pub model_capabilities: Option<AgentModelCapabilities>,
/// Desired model ID (from `Config.model`). Applied after every `session_new_full()`.
pub desired_model: Option<String>,
}
/// 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<String, AcpError> {
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",
+1 -1
View File
@@ -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) {
@@ -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<AgentModelsResponse, String> {
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<ManagedAgentSummary, String> {
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<String>,
) -> 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<AgentModelInfo> = Vec::new();
let mut seen_ids: HashSet<String> = 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<String> = 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,
}
}
+6
View File
@@ -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(),
+2
View File
@@ -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::*;
+2
View File
@@ -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");
@@ -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");
@@ -27,6 +27,11 @@ pub struct ManagedAgentRecord {
#[serde(default = "default_agent_parallelism")]
pub parallelism: u32,
pub system_prompt: Option<String>,
/// 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<String>,
#[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<String>,
pub model: Option<String>,
pub has_api_token: bool,
pub status: String,
pub pid: Option<u32>,
@@ -83,6 +89,7 @@ pub struct CreateManagedAgentRequest {
pub turn_timeout_seconds: Option<u64>,
pub parallelism: Option<u32>,
pub system_prompt: Option<String>,
pub model: Option<String>,
#[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<Option<T>>`:
/// - Field absent in JSON → `None` (don't touch)
/// - `"field": null` → `Some(None)` (clear to default)
/// - `"field": "value"` → `Some(Some("value"))` (set)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateManagedAgentRequest {
pub pubkey: String,
/// Absent = don't touch. null = clear to agent default. "id" = set.
#[serde(default)]
pub model: Option<Option<String>>,
#[serde(default)]
pub system_prompt: Option<Option<String>>,
}
/// Response from `get_agent_models` — normalized model info for the frontend.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentModelsResponse {
pub agent_name: String,
pub agent_version: String,
/// Unified model list (merged from both ACP paths, deduplicated by ID).
pub models: Vec<AgentModelInfo>,
/// The agent's default model for a fresh session.
pub agent_default_model: Option<String>,
/// The user's persisted model selection (from ManagedAgentRecord.model).
pub selected_model: Option<String>,
/// Whether this agent supports model switching.
pub supports_switching: bool,
}
/// A single model available from an agent.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentModelInfo {
/// Canonical ID used for persistence and round-tripping.
pub id: String,
pub name: Option<String>,
pub description: Option<String>,
}
pub const DEFAULT_ACP_COMMAND: &str = "sprout-acp";
pub const DEFAULT_AGENT_COMMAND: &str = "goose";
pub const DEFAULT_MCP_COMMAND: &str = "sprout-mcp-server";
@@ -11,6 +11,7 @@ import type { ManagedAgent } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { CopyButton } from "./CopyButton";
import { ModelPicker } from "./ModelPicker";
import { formatTimestamp, truncatePubkey } from "./agentUi";
export function ManagedAgentCard({
@@ -20,6 +21,7 @@ export function ManagedAgentCard({
isSelected,
onDelete,
onMintToken,
onModelChanged,
onSelect,
onStart,
onStop,
@@ -30,6 +32,7 @@ export function ManagedAgentCard({
isSelected: boolean;
onDelete: (pubkey: string) => void;
onMintToken: (pubkey: string, name: string) => void;
onModelChanged?: () => void;
onSelect: (pubkey: string) => void;
onStart: (pubkey: string) => void;
onStop: (pubkey: string) => void;
@@ -97,6 +100,7 @@ export function ManagedAgentCard({
{agent.hasApiToken ? "Bearer token saved" : "Key-only dev mode"}
</p>
</div>
<ModelPicker agent={agent} onModelChanged={onModelChanged} />
</div>
<div className="mt-4 flex flex-wrap gap-2">
@@ -110,6 +110,7 @@ export function ManagedAgentsSection({
onMintToken(pubkey, name);
}
}}
onModelChanged={onRefresh}
onSelect={onSelect}
onStart={(pubkey) => {
if (!isActionPending) {
@@ -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<AgentModelsResponse | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(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 (
<div className="rounded-2xl border border-border/60 bg-background/70 px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Model
</p>
<Button
className="mt-1 h-7 px-2 text-sm"
onClick={fetchModels}
size="sm"
type="button"
variant="outline"
>
Discover models
</Button>
</div>
);
}
if (loading) {
return (
<div className="rounded-2xl border border-border/60 bg-background/70 px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Model
</p>
<div className="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Discovering
</div>
</div>
);
}
if (error) {
return (
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-destructive">
Model
</p>
<p className="mt-1 text-sm text-destructive">{error}</p>
<Button
className="mt-1 h-6 px-2 text-xs"
onClick={fetchModels}
size="sm"
type="button"
variant="outline"
>
Retry
</Button>
</div>
);
}
if (!modelsData?.supportsSwitching) {
return (
<div className="rounded-2xl border border-border/60 bg-background/70 px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Model
</p>
<p className="mt-1 text-sm text-muted-foreground">Not configurable</p>
</div>
);
}
return (
<div className="rounded-2xl border border-border/60 bg-background/70 px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Model
</p>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
className="mt-1 h-7 max-w-full justify-start gap-1.5 rounded-full border border-border/50 bg-muted/45 px-2.5 text-xs font-medium text-foreground shadow-none hover:bg-muted/70"
disabled={saving}
size="sm"
type="button"
variant="ghost"
>
<span className="truncate">{displayLabel}</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-h-64 min-w-48 overflow-y-auto"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuRadioGroup
onValueChange={handleModelChange}
value={currentValue}
>
{modelsData.models.map((model) => (
<DropdownMenuRadioItem key={model.id} value={model.id}>
{model.name ?? model.id}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{needsRestart ? (
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400">
Restart agent to apply
</p>
) : null}
</div>
);
}
+21
View File
@@ -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<AgentModelsResponse> {
return invokeTauri<AgentModelsResponse>("get_agent_models", { pubkey });
}
export async function updateManagedAgent(
input: UpdateManagedAgentInput,
): Promise<ManagedAgent> {
const response = await invokeTauri<RawManagedAgent>("update_managed_agent", {
input,
});
return fromRawManagedAgent(response);
}
+25
View File
@@ -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;
};
+35
View File
@@ -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<RawManagedAgent> {
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<typeof handleGetManagedAgentLog>[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<typeof handleUpdateManagedAgent>[0],
);
case "create_channel":
return handleCreateChannel(
payload as Parameters<typeof handleCreateChannel>[0],