diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index f46a18839b..0099d9533f 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -7350,6 +7350,10 @@ enum InlineApiKeyWrite<'a> { /// endpoint so it can't bleed into the newly selected provider — mirroring /// Hermes' own `auth.py` cleanup on a provider switch. Clear, + /// Leave `model.api_key` / `model.api_mode` exactly as they are. Used by the + /// model-only write, which never learns the provider's credentials and so + /// must neither write nor scrub them. + Preserve, } /// Set `model.{provider,default,base_url}` in a Hermes config.yaml document, @@ -7432,6 +7436,8 @@ fn merge_hermes_model_config( model_map.remove(Value::String("api_key".to_string())); model_map.remove(Value::String("api_mode".to_string())); } + // Model-only write: the credentials are none of its business. + InlineApiKeyWrite::Preserve => {} } serde_yaml::to_string(&root) @@ -7646,6 +7652,387 @@ async fn load_hermes_local_config_json() -> Option { serde_json::to_string_pretty(&serde_json::Value::Object(merged)).ok() } +/// The console scripts a Hermes install exposes. `hermes` is the ACP entry point +/// codeg's built-in entry launches; `hermes-agent` is the other script the same +/// package ships. A user who wants a SECOND Hermes profile registers a custom +/// agent pointing at one of these with its own `HERMES_HOME`, so anything +/// Hermes-specific has to recognize those entries too — the built-in +/// `AgentType::Hermes` check alone leaves every custom profile behind. +const HERMES_COMMANDS: &[&str] = &["hermes", "hermes-agent"]; + +/// Whether `agent_type` runs Hermes: the built-in entry, or a custom agent whose +/// launch command is one of Hermes' own console scripts. An unregistered custom +/// id (deleted while a conversation still points at it) is not Hermes — there is +/// no definition left to judge, and guessing would target the wrong config dir. +pub(crate) fn is_hermes_agent(agent_type: AgentType) -> bool { + if agent_type == AgentType::Hermes { + return true; + } + let Some(registry_id) = agent_type.custom_id() else { + return false; + }; + let Some(meta) = custom_registry::get(registry_id) else { + return false; + }; + let cmd = match &meta.distribution { + registry::AgentDistribution::Npx { cmd, .. } + | registry::AgentDistribution::Binary { cmd, .. } + | registry::AgentDistribution::Uvx { cmd, .. } => *cmd, + }; + HERMES_COMMANDS.contains(&cmd) +} + +/// The Hermes home `agent_type` actually reads, or `None` when it is not Hermes. +/// +/// A custom Hermes profile carries its `HERMES_HOME` in the agent's stored env, +/// which `merge_agent_env` gives highest precedence at launch — so it must be +/// resolved here exactly as the launched process would (see +/// [`hermes_home_for_launch`]), not through codeg's own `HERMES_HOME`. Getting +/// this wrong is silent and destructive: the model would be written into the +/// DEFAULT profile's config.yaml while the agent keeps reading its own. +pub(crate) async fn hermes_home_for_agent( + agent_type: AgentType, + db: &AppDatabase, +) -> Option { + if !is_hermes_agent(agent_type) { + return None; + } + let env = agent_setting_service::get_by_agent_type(&db.conn, agent_type) + .await + .ok() + .flatten() + .and_then(|m| m.env_json) + .and_then(|raw| serde_json::from_str::>(&raw).ok()) + .unwrap_or_default(); + Some(hermes_home_for_launch(&env)) +} + +/// What the chat-side Hermes model picker needs, in one round trip. +/// +/// `error` rather than a hard failure: the picker must still render the model +/// the agent is actually on when the provider can't be listed (no API key yet, +/// an OAuth provider, a network blip). Losing the list is a degraded picker; +/// losing the current value would make the composer claim the wrong model. +#[derive(Debug, Clone, serde::Serialize)] +pub struct HermesModelOptions { + /// `model.default` from the agent's own config.yaml. + pub current_model: Option, + /// Model ids the provider reports, sorted. Empty when `error` is set. + pub models: Vec, + /// Why the listing failed, for the picker to show inline. `None` on success. + pub error: Option, +} + +/// How a Hermes provider's `/models` endpoint is authenticated. Hermes' own +/// provider table (`HERMES_PROVIDERS`) says where the credentials live; this +/// says how to present them to an OpenAI-compatible listing call. +enum HermesModelAuth { + /// `Authorization: Bearer ` — the OpenAI-compatible majority. + Bearer, + /// `x-api-key` + `anthropic-version` — Anthropic's own listing endpoint. + Anthropic, +} + +/// Resolve how to list models for `provider`, or `None` when codeg cannot. +/// +/// Deliberately conservative. OAuth and AWS-Bedrock providers hold no API key +/// codeg can present, and Gemini is not OpenAI-compatible at `/models`, so for +/// those the honest answer is "no list" — inventing an endpoint would produce a +/// picker full of models the agent cannot actually run. +fn hermes_model_auth_in(home: &Path, provider: &str) -> Option { + if let Some(name) = named_custom_provider_id(provider) { + let root = fs::read_to_string(home.join("config.yaml")) + .ok() + .and_then(|raw| serde_yaml::from_str::(&raw).ok()); + // A user-supplied OpenAI-compatible endpoint — the same shape as the + // bare `custom` provider, so the same Bearer listing works. + if named_custom_provider_entry(root.as_ref(), name).is_some() { + return Some(HermesModelAuth::Bearer); + } + } + hermes_model_auth(provider) +} + +fn hermes_model_auth(provider: &str) -> Option { + match provider { + "anthropic" => Some(HermesModelAuth::Anthropic), + "gemini" | "vertex" | "bedrock" => None, + other => hermes_provider(other).and_then(|p| { + // OAuth providers carry no key var; AWS resolves from the SDK chain. + let keyed = !p.key_env_var.is_empty() || hermes_inlines_api_key(other); + keyed.then_some(HermesModelAuth::Bearer) + }), + } +} + +/// Read a Hermes home's `config.yaml` + `.env` into `(provider, model, base_url, +/// api_key)`, reusing the same projection the settings panel binds to so the +/// picker and the panel can never disagree about which endpoint is in effect. +fn read_hermes_model_config( + home: &Path, +) -> ( + Option, + Option, + Option, + Option, +) { + let env_map = fs::read_to_string(home.join(".env")) + .ok() + .map(|raw| parse_env_file(&raw)) + .unwrap_or_default(); + + let root = fs::read_to_string(home.join("config.yaml")) + .ok() + .and_then(|raw| serde_yaml::from_str::(&raw).ok()); + + let mut provider = None; + let mut model = None; + let mut yaml_base_url = None; + let mut yaml_api_key = None; + if let Some(model_section) = root.as_ref().and_then(|v| v.get("model")) { + provider = yaml_str(model_section, "provider"); + model = yaml_str(model_section, "default"); + yaml_base_url = yaml_str(model_section, "base_url"); + yaml_api_key = yaml_str(model_section, "api_key"); + } + + // A NAMED custom provider resolves through the top-level `providers:` map, + // which the curated-table projection below knows nothing about. Checked + // first so a profile using one is not silently left with no endpoint. + if let Some(entry) = provider + .as_deref() + .and_then(named_custom_provider_id) + .and_then(|name| named_custom_provider_entry(root.as_ref(), name)) + { + let base_url = yaml_str(entry, "base_url").or(yaml_base_url); + let api_key = named_custom_provider_key(entry, &env_map).or(yaml_api_key); + return (provider, model, base_url, api_key); + } + + let (api_key, base_url) = match provider.as_deref() { + Some(p) => project_hermes_key_and_base( + p, + &env_map, + yaml_base_url.as_deref(), + yaml_api_key.as_deref(), + ), + None => (None, yaml_base_url), + }; + (provider, model, base_url, api_key) +} + +/// The `` of a named custom provider (`model.provider: custom:`), or +/// `None` for every other provider — including the bare `custom` provider, whose +/// endpoint and inline key live in the `model:` section instead. +fn named_custom_provider_id(provider: &str) -> Option<&str> { + let name = provider.strip_prefix("custom:")?.trim(); + (!name.is_empty()).then_some(name) +} + +/// The `providers:` entry defining `name`. That map is TOP-LEVEL in config.yaml +/// — a sibling of `model:`, not a child of it. +fn named_custom_provider_entry<'a>( + root: Option<&'a serde_yaml::Value>, + name: &str, +) -> Option<&'a serde_yaml::Value> { + root?.get("providers")?.get(name) +} + +/// The API key of a named custom provider. +/// +/// `key_env` holds the NAME of a `.env` variable, not the key itself — reading it +/// as the key would send the literal string `CUSTOM_…_API_KEY` as the credential +/// and every request would come back 401. An inline `api_key` is honored too, for +/// a profile that stores the secret in config.yaml directly. +fn named_custom_provider_key( + entry: &serde_yaml::Value, + env_map: &BTreeMap, +) -> Option { + if let Some(var) = yaml_str(entry, "key_env") { + if let Some(value) = env_map.get(&var).filter(|v| !v.trim().is_empty()) { + return Some(value.clone()); + } + } + yaml_str(entry, "api_key") +} + +/// GET `/models` and return the reported ids, sorted and de-duplicated. +/// Mirrors [`acp_fetch_kimi_models_core`], differing only in how the credential +/// is presented (see [`HermesModelAuth`]). +async fn list_openai_compatible_models( + base_url: &str, + api_key: &str, + auth: HermesModelAuth, +) -> Result, AcpError> { + let base = base_url.trim().trim_end_matches('/'); + let url = format!("{base}/models"); + let request = reqwest::Client::new() + .get(&url) + .timeout(std::time::Duration::from_secs(20)); + let request = match auth { + HermesModelAuth::Bearer => request.bearer_auth(api_key), + HermesModelAuth::Anthropic => request + .header("x-api-key", api_key) + .header("anthropic-version", "2023-06-01"), + }; + let resp = request + .send() + .await + .map_err(|e| AcpError::protocol(format!("list models request failed: {e}")))?; + let status = resp.status(); + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| AcpError::protocol(format!("list models returned invalid JSON: {e}")))?; + if !status.is_success() { + let msg = body + .get("error") + .and_then(|e| e.get("message")) + .and_then(serde_json::Value::as_str) + .unwrap_or("request rejected"); + return Err(AcpError::protocol(format!("{status}: {msg}"))); + } + let mut ids: Vec = body + .get("data") + .and_then(serde_json::Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|m| { + m.get("id") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .collect() + }) + .unwrap_or_default(); + ids.sort(); + ids.dedup(); + Ok(ids) +} + +/// Current model + the provider's model list for the chat-side Hermes picker, +/// or `None` when `agent_type` is not Hermes. +/// +/// `None` rather than an error because the composer asks this of whatever agent +/// it is bound to: "not Hermes" is the ordinary answer for most agents, and an +/// error would put a failure on screen for a question that was answered fine. +/// +/// A LISTING problem is not a failure either — the reason lands in `error` so +/// the picker degrades to "current model only" instead of vanishing. Dropping +/// the list costs the user a convenience; dropping `current_model` would make +/// the composer claim the wrong model. +pub(crate) async fn acp_hermes_model_options_core( + agent_type: AgentType, + db: &AppDatabase, +) -> Result, AcpError> { + let Some(home) = hermes_home_for_agent(agent_type, db).await else { + return Ok(None); + }; + let (provider, current_model, base_url, api_key) = read_hermes_model_config(&home); + + let fail = |error: String| HermesModelOptions { + current_model: current_model.clone(), + models: Vec::new(), + error: Some(error), + }; + + let Some(provider) = provider else { + return Ok(Some(fail( + "hermes config.yaml has no model.provider".to_string(), + ))); + }; + let Some(auth) = hermes_model_auth_in(&home, &provider) else { + return Ok(Some(fail(format!( + "provider `{provider}` cannot be listed over an API — set the model in agent settings" + )))); + }; + let Some(base_url) = base_url.filter(|v| !v.trim().is_empty()) else { + return Ok(Some(fail(format!( + "provider `{provider}` has no base URL — set one in agent settings" + )))); + }; + let Some(api_key) = api_key.filter(|v| !v.trim().is_empty()) else { + return Ok(Some(fail(format!( + "provider `{provider}` has no API key — set one in agent settings" + )))); + }; + + match list_openai_compatible_models(&base_url, &api_key, auth).await { + Ok(models) => Ok(Some(HermesModelOptions { + current_model, + models, + error: None, + })), + Err(e) => Ok(Some(fail(e.to_string()))), + } +} + +/// Point `agent_type`'s Hermes profile at `model` by rewriting `model.default` +/// in its own config.yaml, leaving every other key (provider, base_url, inline +/// key, mcp_servers, …) exactly as it was. +/// +/// Hermes reads config.yaml at process start, so this cannot reach a running +/// session; the caller reconnects to apply it. +pub(crate) async fn acp_set_hermes_model_core( + agent_type: AgentType, + model: &str, + db: &AppDatabase, + emitter: &EventEmitter, +) -> Result<(), AcpError> { + let model = model.trim(); + if model.is_empty() { + return Err(AcpError::protocol("model is required")); + } + let Some(home) = hermes_home_for_agent(agent_type, db).await else { + return Err(AcpError::protocol("agent is not a Hermes agent")); + }; + let config_path = home.join("config.yaml"); + let existing = fs::read_to_string(&config_path).ok(); + // The provider is whatever the profile already declares: this call only + // switches models. An absent provider means the profile was never set up, + // and writing a bare `model.default` would leave a config Hermes can't use. + let (provider, ..) = read_hermes_model_config(&home); + let Some(provider) = provider else { + return Err(AcpError::protocol( + "hermes config.yaml has no model.provider — set up the agent first", + )); + }; + let merged = merge_hermes_model_config( + existing.as_deref(), + &provider, + model, + BaseUrlWrite::Preserve, + InlineApiKeyWrite::Preserve, + )?; + ensure_hermes_home_secure(&home)?; + write_hermes_secret_file(&config_path, &merged, "config.yaml")?; + emit_acp_agents_updated(emitter, "config_updated", Some(agent_type)); + Ok(()) +} + +/// `acp_set_hermes_model_core` followed by a session staleness refresh. Shared +/// by the Tauri command and the web handler; returns the count of running +/// sessions of that agent left on stale (launch-time) config — including the +/// caller's own, which reconnects to apply the new model. +pub(crate) async fn acp_set_hermes_model_and_refresh( + agent_type: AgentType, + model: &str, + db: &AppDatabase, + manager: &ConnectionManager, + data_dir: &Path, + emitter: &EventEmitter, +) -> Result { + acp_set_hermes_model_core(agent_type, model, db, emitter).await?; + Ok(refresh_config_staleness( + manager, + db, + data_dir, + &[agent_type], + ConfigStaleKind::AgentConfig, + ) + .await) +} + /// Structured Hermes config update from the settings UI. #[derive(Debug, Clone)] pub(crate) struct HermesConfigUpdate { @@ -10464,6 +10851,18 @@ pub(crate) fn fingerprint_config( hasher.update(json.as_bytes()); } } + // Same again for Hermes' config.yaml, which carries the model, provider and + // endpoint and is read once at process start. Without this, switching the + // model leaves every OTHER running session of that agent silently on the old + // one with nothing on screen to say so. Keyed on the home the launch will + // actually use, so each custom Hermes profile is fingerprinted separately. + if is_hermes_agent(agent_type) { + hasher.update(b"\x01hermes_yaml\x01"); + if let Ok(yaml) = fs::read_to_string(hermes_home_for_launch(runtime_env).join("config.yaml")) + { + hasher.update(yaml.as_bytes()); + } + } format!("{:x}", hasher.finalize()) } @@ -12066,6 +12465,41 @@ pub async fn acp_update_kimi_code_config( .await } +/// Current model + the provider's model list for a Hermes agent's chat-side +/// model picker. Desktop command; the web handler calls +/// `acp_hermes_model_options_core` directly. +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn acp_hermes_model_options( + agent_type: AgentType, + db: State<'_, AppDatabase>, +) -> Result, AcpError> { + acp_hermes_model_options_core(agent_type, &db).await +} + +/// Switch a Hermes agent's model by rewriting `model.default` in its own +/// config.yaml, then report how many running sessions that left on stale +/// (launch-time) config. Desktop command; the web handler calls +/// `acp_set_hermes_model_and_refresh` directly. +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn acp_set_hermes_model( + agent_type: AgentType, + model: String, + manager: State<'_, ConnectionManager>, + db: State<'_, AppDatabase>, + app: tauri::AppHandle, +) -> Result { + let app_data_dir = app + .path() + .app_data_dir() + .map(|p| crate::paths::resolve_effective_data_dir(&p)) + .unwrap_or_else(|_| std::path::PathBuf::from(".")); + let emitter = EventEmitter::Tauri(app); + acp_set_hermes_model_and_refresh(agent_type, &model, &db, &manager, &app_data_dir, &emitter) + .await +} + /// List the models an API key + endpoint can access (validates the key and /// populates the Kimi settings model picker). Desktop command; the web handler /// calls `acp_fetch_kimi_models_core` directly. @@ -17784,6 +18218,237 @@ wire_api = "chat" ); } + #[test] + fn a_custom_hermes_profile_is_recognized_as_hermes() { + use crate::acp::custom_registry::{ + hydrate, hydrate_test_guard, CustomAgentDef, CustomAgentSpec, CustomDistributionKind, + NpxSpec, + }; + let _guard = hydrate_test_guard(); + + // WHY this matters: a second Hermes profile is registered as a CUSTOM + // agent pointing at Hermes' own `hermes` bin with its own HERMES_HOME. + // If only `AgentType::Hermes` counted as Hermes, that profile would get + // no model picker and — worse — a model write aimed at it would land in + // the DEFAULT profile's config.yaml while the agent kept reading its own. + let hermes_profile = CustomAgentDef { + registry_id: "hermes-work".into(), + name: "Hermes (work)".into(), + description: String::new(), + version: "0.21.3".into(), + distribution_kind: CustomDistributionKind::Npx, + spec: CustomAgentSpec { + npx: Some(NpxSpec { + package: "hermes-agent@0.21.3".into(), + cmd: Some("hermes".into()), + ..Default::default() + }), + ..Default::default() + }, + icon_url: None, + skills_shared_store: false, + skills_dir: None, + source: Default::default(), + version_probe: None, + supports_mcp: true, + }; + // A custom agent that is NOT Hermes must stay out: giving it a Hermes + // picker would write a `model.default` into a config it never reads. + let other = CustomAgentDef { + registry_id: "qwen-code".into(), + name: "Qwen Code".into(), + description: String::new(), + version: "1.0.0".into(), + distribution_kind: CustomDistributionKind::Npx, + spec: CustomAgentSpec { + npx: Some(NpxSpec { + package: "qwen-code@1.0.0".into(), + ..Default::default() + }), + ..Default::default() + }, + icon_url: None, + skills_shared_store: false, + skills_dir: None, + source: Default::default(), + version_probe: None, + supports_mcp: true, + }; + assert!(hydrate(&[hermes_profile, other]).is_empty()); + + assert!(is_hermes_agent(AgentType::Hermes)); + assert!(is_hermes_agent( + AgentType::custom("hermes-work").expect("custom id") + )); + assert!(!is_hermes_agent( + AgentType::custom("qwen-code").expect("custom id") + )); + assert!(!is_hermes_agent(AgentType::Codex)); + // An id deleted while a conversation still points at it has no + // definition left to judge — guessing would target the wrong config dir. + assert!(!is_hermes_agent( + AgentType::custom("hermes-gone").expect("custom id") + )); + + assert!(hydrate(&[]).is_empty()); + } + + #[test] + fn hermes_model_listing_is_offered_only_where_codeg_can_actually_list() { + // WHY: an unlistable provider must degrade to "no list", never to a + // guessed endpoint — a picker full of models the agent cannot run is + // worse than no picker, because the user only finds out at send time. + assert!(matches!( + hermes_model_auth("openrouter"), + Some(HermesModelAuth::Bearer) + )); + assert!(matches!( + hermes_model_auth("custom"), + Some(HermesModelAuth::Bearer) + )); + // Anthropic lists over `x-api-key` + `anthropic-version`, not Bearer. + assert!(matches!( + hermes_model_auth("anthropic"), + Some(HermesModelAuth::Anthropic) + )); + // Gemini is not OpenAI-compatible at `/models`; OAuth and AWS providers + // hold no key codeg can present; an unknown provider is undiscoverable. + assert!(hermes_model_auth("gemini").is_none()); + assert!(hermes_model_auth("qwen-oauth").is_none()); + assert!(hermes_model_auth("bedrock").is_none()); + assert!(hermes_model_auth("unknown-provider").is_none()); + } + + #[test] + fn reading_a_profile_reports_its_model_provider_and_endpoint() { + let tmp = tempfile::tempdir().expect("tempdir"); + let home = tmp.path(); + fs::write( + home.join("config.yaml"), + "model:\n provider: openai-api\n default: gpt-4o\n", + ) + .unwrap(); + // The endpoint lives only in `.env` here — the settings panel already + // treats that as authoritative, and the picker must agree with it or the + // two would disagree about which account is being listed. + fs::write( + home.join(".env"), + "OPENAI_API_KEY=sk-test\nOPENAI_BASE_URL=https://gw.example/v1\n", + ) + .unwrap(); + + let (provider, model, base_url, api_key) = read_hermes_model_config(home); + assert_eq!(provider.as_deref(), Some("openai-api")); + assert_eq!(model.as_deref(), Some("gpt-4o")); + assert_eq!(base_url.as_deref(), Some("https://gw.example/v1")); + assert_eq!(api_key.as_deref(), Some("sk-test")); + } + + #[test] + fn a_named_custom_provider_resolves_through_the_providers_map() { + // WHY: Hermes lets a user define NAMED custom providers — `model.provider: + // custom:`, whose endpoint and key-var name live in the TOP-LEVEL + // `providers:` map, not in the `model:` section and not in codeg's + // curated provider table. Resolving only against that table leaves the + // model picker with no endpoint and no key, so it silently degrades to + // "current model only" — which is exactly what a real 9router profile hit. + let tmp = tempfile::tempdir().expect("tempdir"); + let home = tmp.path(); + fs::write( + home.join("config.yaml"), + concat!( + "model:\n", + " default: ocg/deepseek-flash\n", + " provider: custom:9router\n", + "providers:\n", + " 9router:\n", + " name: 9router\n", + " base_url: http://127.0.0.1:20128/v1\n", + " key_env: CUSTOM_9ROUTER_API_KEY\n", + " default_model: ocg/deepseek-flash\n", + ), + ) + .unwrap(); + fs::write(home.join(".env"), "CUSTOM_9ROUTER_API_KEY=sk-9r\n").unwrap(); + + let (provider, model, base_url, api_key) = read_hermes_model_config(home); + assert_eq!(provider.as_deref(), Some("custom:9router")); + assert_eq!(model.as_deref(), Some("ocg/deepseek-flash")); + assert_eq!( + base_url.as_deref(), + Some("http://127.0.0.1:20128/v1"), + "the endpoint comes from providers..base_url" + ); + assert_eq!( + api_key.as_deref(), + Some("sk-9r"), + "`key_env` names the .env variable holding the key, it is not the key" + ); + // And such a provider IS listable: it is an OpenAI-compatible endpoint. + assert!(matches!( + hermes_model_auth_in(home, "custom:9router"), + Some(HermesModelAuth::Bearer) + )); + } + + #[test] + fn switching_the_model_leaves_the_rest_of_the_profile_alone() { + // WHY: the chat-side picker only ever means "use this model". If the + // model-only write also touched the endpoint or the inline key, picking + // a model from the composer would silently break a working `custom` + // provider — and the user would have no reason to suspect the picker. + let existing = "model:\n provider: custom\n default: old-model\n base_url: https://local/v1\n api_key: sk-inline\n api_mode: anthropic_messages\nmcp_servers:\n fs:\n command: mcp-fs\n"; + let merged = merge_hermes_model_config( + Some(existing), + "custom", + "new-model", + BaseUrlWrite::Preserve, + InlineApiKeyWrite::Preserve, + ) + .expect("merge"); + + let value: serde_yaml::Value = serde_yaml::from_str(&merged).expect("yaml"); + let model = value.get("model").expect("model section"); + assert_eq!(yaml_str(model, "default").as_deref(), Some("new-model")); + assert_eq!(yaml_str(model, "provider").as_deref(), Some("custom")); + assert_eq!( + yaml_str(model, "base_url").as_deref(), + Some("https://local/v1") + ); + assert_eq!(yaml_str(model, "api_key").as_deref(), Some("sk-inline")); + assert_eq!( + yaml_str(model, "api_mode").as_deref(), + Some("anthropic_messages") + ); + assert!( + value.get("mcp_servers").is_some(), + "unrelated top-level sections must survive a model switch" + ); + } + + #[test] + fn a_hermes_config_change_marks_running_sessions_stale() { + // WHY: Hermes reads config.yaml at process start. Without the config in + // the fingerprint, switching the model would leave every other running + // session of that agent on the old model with nothing on screen saying + // so — the exact failure the stale banner exists to prevent. + let tmp = tempfile::tempdir().expect("tempdir"); + let home = tmp.path(); + let mut env = BTreeMap::new(); + env.insert("HERMES_HOME".to_string(), home.display().to_string()); + + fs::write(home.join("config.yaml"), "model:\n default: model-a\n").unwrap(); + let before = fingerprint_config(AgentType::Hermes, &env); + fs::write(home.join("config.yaml"), "model:\n default: model-b\n").unwrap(); + let after = fingerprint_config(AgentType::Hermes, &env); + assert_ne!(before, after, "a model switch must change the fingerprint"); + + // And an agent that never reads this file must not be perturbed by it. + let codex_before = fingerprint_config(AgentType::Codex, &env); + fs::write(home.join("config.yaml"), "model:\n default: model-c\n").unwrap(); + assert_eq!(codex_before, fingerprint_config(AgentType::Codex, &env)); + } + #[test] fn hermes_home_for_launch_matches_hermes_resolution() { // A non-empty override is used VERBATIM — Hermes' get_hermes_home does diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 498d92e858..8ffaa381da 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1707,6 +1707,8 @@ mod tauri_app { acp_commands::acp_update_agent_config, acp_commands::acp_update_hermes_config, acp_commands::acp_update_kimi_code_config, + acp_commands::acp_hermes_model_options, + acp_commands::acp_set_hermes_model, acp_commands::acp_fetch_kimi_models, deepseek_settings_commands::acp_load_deepseek_model_catalog, deepseek_settings_commands::acp_update_deepseek_model_catalog, diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index ecd701e3ac..49910d46a0 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -884,6 +884,47 @@ pub async fn acp_update_kimi_code_config( Ok(Json(affected)) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpHermesModelOptionsParams { + pub agent_type: AgentType, +} + +pub async fn acp_hermes_model_options( + Extension(state): Extension>, + Json(params): Json, +) -> Result>, AppCommandError> { + let options = acp_commands::acp_hermes_model_options_core(params.agent_type, &state.db) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(options)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpSetHermesModelParams { + pub agent_type: AgentType, + pub model: String, +} + +pub async fn acp_set_hermes_model( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let emitter = state.emitter.clone(); + let affected = acp_commands::acp_set_hermes_model_and_refresh( + params.agent_type, + ¶ms.model, + &state.db, + &state.connection_manager, + &state.data_dir, + &emitter, + ) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(affected)) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct AcpFetchKimiModelsParams { diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 4875810a42..104d6b0d6c 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -899,6 +899,14 @@ pub fn build_router( "/acp_update_kimi_code_config", post(handlers::acp::acp_update_kimi_code_config), ) + .route( + "/acp_hermes_model_options", + post(handlers::acp::acp_hermes_model_options), + ) + .route( + "/acp_set_hermes_model", + post(handlers::acp::acp_set_hermes_model), + ) .route( "/acp_fetch_kimi_models", post(handlers::acp::acp_fetch_kimi_models), diff --git a/src/components/canvas/canvas-conversation-surface.tsx b/src/components/canvas/canvas-conversation-surface.tsx index 87bdc1e4a0..c2e06b4ce1 100644 --- a/src/components/canvas/canvas-conversation-surface.tsx +++ b/src/components/canvas/canvas-conversation-surface.tsx @@ -17,6 +17,10 @@ import { MessageListView } from "@/components/message/message-list-view" import { useAcpActions } from "@/contexts/acp-connections-context" import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle" import { useConversationDetail } from "@/hooks/use-conversation-detail" +import { + HERMES_MODEL_CONFIG_ID, + useHermesModelOption, +} from "@/hooks/use-hermes-model-option" import { acpStopAsyncTask, createChatConversation, @@ -701,10 +705,37 @@ export function CanvasConversationSurface({ () => conn.modes?.available_modes ?? [], [conn.modes] ) - const connectionConfigOptions = useMemo( + const acpConfigOptions = useMemo( () => conn.configOptions ?? [], [conn.configOptions] ) + // Same synthetic Hermes model option as the detail panel — the canvas surface + // hosts the same composer, so it must offer the same picker. + const { option: hermesModelOption, selectModel: selectHermesModel } = + useHermesModelOption({ + agentType, + configOptions: acpConfigOptions, + status: conn.status, + reapplyConfig: conn.reapplyConfig, + canReconnect: !conn.isViewer && !conn.isDelegationChild, + }) + const connectionConfigOptions = useMemo( + () => + hermesModelOption + ? [...acpConfigOptions, hermesModelOption] + : acpConfigOptions, + [acpConfigOptions, hermesModelOption] + ) + const handleConfigOptionChange = useCallback( + (configId: string, valueId: string) => { + if (configId === HERMES_MODEL_CONFIG_ID) { + selectHermesModel(valueId) + return + } + handleSetConfigOption(configId, valueId) + }, + [handleSetConfigOption, selectHermesModel] + ) const selectedModeId = useMemo(() => { if (connectionModes.length === 0) return null if (modeId && connectionModes.some((mode) => mode.id === modeId)) { @@ -761,7 +792,7 @@ export function CanvasConversationSurface({ selectorsLoading={selectorsLoading} selectedModeId={selectedModeId} onModeChange={handleModeChange} - onConfigOptionChange={handleSetConfigOption} + onConfigOptionChange={handleConfigOptionChange} agentType={agentType} availableCommands={conn.availableCommands ?? []} draftStorageKey={`canvas-draft:${contextKey}`} diff --git a/src/components/chat/hermes-model-option-wiring.test.ts b/src/components/chat/hermes-model-option-wiring.test.ts new file mode 100644 index 0000000000..f3cca3d151 --- /dev/null +++ b/src/components/chat/hermes-model-option-wiring.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs" +import { resolve } from "node:path" +import { describe, expect, it } from "vitest" + +import { HERMES_MODEL_CONFIG_ID } from "@/hooks/use-hermes-model-option" + +// Every surface that mounts the composer. A new one that forgets the routing +// would send codeg's synthetic id to the agent; a new one that forgets the +// option would silently drop the picker on that surface. +const SURFACES = [ + "src/components/conversations/conversation-detail-panel.tsx", + "src/components/canvas/canvas-conversation-surface.tsx", +] as const + +const sources = SURFACES.map( + (path) => [path, readFileSync(resolve(process.cwd(), path), "utf8")] as const +) + +describe("Hermes model option wiring", () => { + /** + * WHY: `configOptions` is the list the composer hands straight to the + * backend's `session/set_config_option` on a pick. codeg's synthetic Hermes + * model option is not one of those — Hermes never advertised it — so every + * surface that offers the option MUST also intercept its id. A surface that + * merges the option but routes it like an ACP one fails at the moment the + * user picks a model: the agent rejects an id it does not know, and the model + * silently stays where it was. + */ + it.each(sources)( + "%s intercepts the synthetic id instead of forwarding it to the agent", + (_path, source) => { + expect(source).toContain("useHermesModelOption") + expect(source).toContain("hermesModelOption") + expect(source).toContain(`if (configId === HERMES_MODEL_CONFIG_ID)`) + // The composer must receive the intercepting handler, never the raw ACP one. + expect(source).toContain( + "onConfigOptionChange={handleConfigOptionChange}" + ) + expect(source).not.toContain( + "onConfigOptionChange={handleSetConfigOption}" + ) + } + ) + + // The prefix is the whole reason the interception is recognisable. Renaming + // the id without keeping it namespaced would make it indistinguishable from + // an agent-advertised option. + it("keeps the synthetic id namespaced to codeg", () => { + expect(HERMES_MODEL_CONFIG_ID).toBe("codeg:hermes-model") + }) +}) diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index d08580a6aa..15b595e95a 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -105,6 +105,10 @@ import { } from "@/stores/conversation-runtime-store" import { useShallow } from "zustand/react/shallow" import { useConversationDetail } from "@/hooks/use-conversation-detail" +import { + HERMES_MODEL_CONFIG_ID, + useHermesModelOption, +} from "@/hooks/use-hermes-model-option" import { buildSteerPayload, extractUserImagesFromDraft, @@ -705,10 +709,40 @@ const ConversationTabView = memo(function ConversationTabView({ () => effectiveModes?.available_modes ?? [], [effectiveModes] ) - const connectionConfigOptions = useMemo( + const acpConfigOptions = useMemo( () => effectiveConfigOptions ?? [], [effectiveConfigOptions] ) + // Hermes publishes no model selector over ACP, so codeg synthesizes one from + // the profile's own config.yaml (see `useHermesModelOption`). Null for every + // other agent, and for a Hermes build that ever starts advertising its own. + const { option: hermesModelOption, selectModel: selectHermesModel } = + useHermesModelOption({ + agentType: selectedAgent, + configOptions: acpConfigOptions, + status: connStatus, + reapplyConfig: conn.reapplyConfig, + canReconnect: !conn.isViewer && !conn.isDelegationChild, + }) + const connectionConfigOptions = useMemo( + () => + hermesModelOption + ? [...acpConfigOptions, hermesModelOption] + : acpConfigOptions, + [acpConfigOptions, hermesModelOption] + ) + // The synthetic option is codeg's, not the agent's: routing it to the ACP + // `session/set_config_option` would hand Hermes an id it never advertised. + const handleConfigOptionChange = useCallback( + (configId: string, valueId: string) => { + if (configId === HERMES_MODEL_CONFIG_ID) { + selectHermesModel(valueId) + return + } + handleSetConfigOption(configId, valueId) + }, + [handleSetConfigOption, selectHermesModel] + ) const connectionCommands = useMemo( () => (connIsForOtherAgent ? [] : (conn.availableCommands ?? [])), [connIsForOtherAgent, conn.availableCommands] @@ -2210,7 +2244,7 @@ const ConversationTabView = memo(function ConversationTabView({ selectorsLoading={selectorsLoading} selectedModeId={selectedModeId} onModeChange={handleModeChange} - onConfigOptionChange={handleSetConfigOption} + onConfigOptionChange={handleConfigOptionChange} agentType={selectedAgent} availableCommands={connectionCommands} attachmentTabId={tabId} @@ -2347,7 +2381,7 @@ const ConversationTabView = memo(function ConversationTabView({ selectorsLoading={selectorsLoading} selectedModeId={selectedModeId} onModeChange={handleModeChange} - onConfigOptionChange={handleSetConfigOption} + onConfigOptionChange={handleConfigOptionChange} agentType={selectedAgent} availableCommands={connectionCommands} attachmentTabId={tabId} diff --git a/src/hooks/use-hermes-model-option.test.tsx b/src/hooks/use-hermes-model-option.test.tsx new file mode 100644 index 0000000000..72d9182e9a --- /dev/null +++ b/src/hooks/use-hermes-model-option.test.tsx @@ -0,0 +1,269 @@ +import { act, renderHook, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import type { HermesModelOptions, SessionConfigOptionInfo } from "@/lib/types" + +const api = vi.hoisted(() => ({ + acpHermesModelOptions: vi.fn(), + acpSetHermesModel: vi.fn(), +})) + +vi.mock("@/lib/api", () => api) + +// The agent-settings-changed event the hook listens on, driven by hand. +const platform = vi.hoisted(() => { + const handlers = new Set<() => void>() + return { + emitAgentsUpdated: () => { + for (const h of [...handlers]) h() + }, + // Deliberately NOT reset between tests: the hook subscribes once per module + // for the whole app session, so clearing the handlers here would silently + // disconnect every test after the first one that mounts it. + subscribe: vi.fn(async (_event: string, handler: () => void) => { + handlers.add(handler) + return () => handlers.delete(handler) + }), + } +}) + +vi.mock("@/lib/platform", () => ({ subscribe: platform.subscribe })) + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, +})) + +import { + HERMES_MODEL_CONFIG_ID, + useHermesModelOption, +} from "@/hooks/use-hermes-model-option" + +function options(over: Partial = {}): HermesModelOptions { + return { current_model: "gpt-4o", models: ["gpt-4o"], error: null, ...over } +} + +// A fresh agent id per test: the hook caches per agent for the app session, so +// reusing one would leak the previous test's catalogue into the next. +let seq = 0 +const nextAgent = () => `custom:hermes-${seq++}` + +function render( + over: Partial[0]> = {} +) { + const reapplyConfig = vi.fn().mockResolvedValue(true) + const props = { + agentType: nextAgent(), + configOptions: [] as SessionConfigOptionInfo[], + status: "connected" as const, + reapplyConfig, + canReconnect: true, + ...over, + } + const view = renderHook((p: typeof props) => useHermesModelOption(p), { + initialProps: props, + }) + return { ...view, props, reapplyConfig } +} + +beforeEach(() => { + vi.clearAllMocks() + api.acpHermesModelOptions.mockResolvedValue(options()) + api.acpSetHermesModel.mockResolvedValue(0) +}) + +describe("Hermes model picker", () => { + // WHY: the composer asks every agent for this. For a non-Hermes agent the + // backend answers `null`, and offering a picker anyway would write a + // `model.default` into a config that agent never reads. + it("contributes nothing when the agent is not Hermes", async () => { + api.acpHermesModelOptions.mockResolvedValue(null) + const { result } = render() + await waitFor(() => expect(api.acpHermesModelOptions).toHaveBeenCalled()) + expect(result.current.option).toBeNull() + }) + + // WHY: an agent that advertises its own model selector over ACP owns it. + // Appending a second one would give the composer two model dropdowns, and the + // synthetic one would write to a file the agent may not even read. + it("stays out of the way when the agent advertises its own model option", () => { + const { result } = render({ + configOptions: [ + { + id: "model", + name: "Model", + kind: { type: "select", current_value: "x", options: [], groups: [] }, + }, + ], + }) + expect(result.current.option).toBeNull() + expect(api.acpHermesModelOptions).not.toHaveBeenCalled() + }) + + // WHY: the trigger label resolves against the option's own value list. A + // current model missing from the provider's catalogue (a stale id, or a + // catalogue codeg could not fetch) would otherwise render a blank trigger — + // the composer would stop saying which model it is on. + it("always offers the current model, even when the catalogue omits it", async () => { + api.acpHermesModelOptions.mockResolvedValue( + options({ current_model: "retired-model", models: ["a", "b"] }) + ) + const { result } = render() + await waitFor(() => expect(result.current.option).not.toBeNull()) + const kind = result.current.option!.kind + expect(kind.type).toBe("select") + if (kind.type !== "select") throw new Error("expected a select option") + expect(kind.current_value).toBe("retired-model") + expect(kind.options.map((o) => o.value)).toEqual([ + "retired-model", + "a", + "b", + ]) + }) + + // WHY: this is codeg's option, not the agent's. The id must be recognisable + // as such so the composer routes it to the Hermes writer — handing it to the + // ACP `session/set_config_option` would give Hermes an id it never published. + it("marks the option as codeg's own", async () => { + expect(HERMES_MODEL_CONFIG_ID.startsWith("codeg:")).toBe(true) + const { result } = render() + await waitFor(() => expect(result.current.option).not.toBeNull()) + expect(result.current.option!.id).toBe(HERMES_MODEL_CONFIG_ID) + }) + + it("writes the model and reconnects so the agent re-reads its config", async () => { + const { result, props, reapplyConfig } = render() + await waitFor(() => expect(result.current.option).not.toBeNull()) + + await act(async () => { + result.current.selectModel("new-model") + }) + await waitFor(() => expect(reapplyConfig).toHaveBeenCalledTimes(1)) + expect(api.acpSetHermesModel).toHaveBeenCalledWith({ + agentType: props.agentType, + model: "new-model", + }) + }) + + // WHY the user asked for this: Hermes only reads config.yaml at startup, so + // applying a model means restarting the process. Doing that mid-turn would + // throw away the answer being streamed, so the reconnect waits for the turn. + it("defers the reconnect until the running turn finishes", async () => { + const { result, rerender, props, reapplyConfig } = render({ + status: "prompting", + }) + await waitFor(() => expect(result.current.option).not.toBeNull()) + + await act(async () => { + result.current.selectModel("new-model") + }) + await waitFor(() => expect(api.acpSetHermesModel).toHaveBeenCalled()) + expect(reapplyConfig).not.toHaveBeenCalled() + + rerender({ ...props, status: "connected" }) + await waitFor(() => expect(reapplyConfig).toHaveBeenCalledTimes(1)) + }) + + // WHY: the write is async. If the turn finishes while it is in flight, a + // status captured when the pick was made is already wrong — parking the + // reconnect on it would wait for a transition that already happened, leaving + // the new model unapplied until the user happened to run another turn. + it("still applies when the turn ends while the write is in flight", async () => { + let resolveWrite: (() => void) | undefined + api.acpSetHermesModel.mockReturnValue( + new Promise((resolve) => { + resolveWrite = () => resolve(0) + }) + ) + const { result, rerender, props, reapplyConfig } = render({ + status: "prompting", + }) + await waitFor(() => expect(result.current.option).not.toBeNull()) + + act(() => { + result.current.selectModel("new-model") + }) + // The turn finishes BEFORE the write resolves. + rerender({ ...props, status: "connected" }) + await act(async () => { + resolveWrite?.() + }) + + await waitFor(() => expect(reapplyConfig).toHaveBeenCalledTimes(1)) + }) + + /** + * WHY: the catalogue belongs to ONE provider — whichever the profile's + * `model.provider` names. Settings live in a separate window, so switching + * the provider there reaches the composer only through this event. Without + * the invalidation the picker keeps serving the PREVIOUS provider's models: + * the list looks perfectly healthy, and the user only finds out it was wrong + * when the agent rejects the model at send time. + */ + it("re-lists after the provider changes in settings", async () => { + api.acpHermesModelOptions.mockResolvedValue( + options({ current_model: "gpt-4o", models: ["gpt-4o", "gpt-4o-mini"] }) + ) + const { result } = render() + await waitFor(() => expect(result.current.option).not.toBeNull()) + + api.acpHermesModelOptions.mockResolvedValue( + options({ + current_model: "claude-opus-4", + models: ["claude-opus-4", "claude-sonnet-4"], + }) + ) + await act(async () => { + platform.emitAgentsUpdated() + }) + + await waitFor(() => { + const kind = result.current.option!.kind + if (kind.type !== "select") throw new Error("expected a select option") + expect(kind.options.map((o) => o.value)).toEqual([ + "claude-opus-4", + "claude-sonnet-4", + ]) + }) + expect(api.acpHermesModelOptions).toHaveBeenCalledTimes(2) + }) + + // WHY: a viewer or delegation child does not own the agent process, so + // reconnecting is not theirs to do — the same rule the stale-config banner + // follows. Killing an owner's process from a viewer's picker would take the + // owner's session down with it. + it("never reconnects a session this client does not own", async () => { + const { result, reapplyConfig } = render({ canReconnect: false }) + await waitFor(() => expect(result.current.option).not.toBeNull()) + + await act(async () => { + result.current.selectModel("new-model") + }) + await waitFor(() => expect(api.acpSetHermesModel).toHaveBeenCalled()) + expect(reapplyConfig).not.toHaveBeenCalled() + }) + + // WHY: the optimistic pick is what makes the dropdown feel responsive, but if + // the write failed the composer would then name a model the agent is not on. + it("puts the real model back when the write fails", async () => { + api.acpSetHermesModel.mockRejectedValue(new Error("disk full")) + const { result } = render() + await waitFor(() => expect(result.current.option).not.toBeNull()) + + await act(async () => { + result.current.selectModel("new-model") + }) + await waitFor(() => { + const kind = result.current.option!.kind + if (kind.type !== "select") throw new Error("expected a select option") + expect(kind.current_value).toBe("gpt-4o") + }) + }) +}) diff --git a/src/hooks/use-hermes-model-option.ts b/src/hooks/use-hermes-model-option.ts new file mode 100644 index 0000000000..545e5fa1f2 --- /dev/null +++ b/src/hooks/use-hermes-model-option.ts @@ -0,0 +1,271 @@ +"use client" + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" + +import { acpHermesModelOptions, acpSetHermesModel } from "@/lib/api" +import { isModelConfigOption } from "@/lib/model-config-groups" +import { subscribe } from "@/lib/platform" +import type { + AgentType, + ConnectionStatus, + HermesModelOptions, + SessionConfigOptionInfo, +} from "@/lib/types" + +/** + * Id of the synthetic model option this hook contributes to the composer. + * + * The `codeg:` prefix marks it as codeg's own so the composer's change handler + * routes it to the Hermes writer. It must never reach the backend's + * `session/set_config_option` — Hermes advertises no such option and would + * reject the id. + */ +export const HERMES_MODEL_CONFIG_ID = "codeg:hermes-model" + +/** + * Per-agent cache of the last successful lookup, so opening a second tab on the + * same Hermes profile doesn't re-list the provider's catalogue (OpenRouter's is + * hundreds of rows). Keyed by agent type because that is what resolves the + * profile's HERMES_HOME. + * + * Dropped wholesale whenever agent settings change — see `agentsUpdatedEvent`. + * The catalogue belongs to ONE provider (the profile's `model.provider`), so a + * provider switch in the settings window invalidates every row of it. Serving + * the old provider's models after that is the worst possible failure: the list + * looks fine, and the user only learns it was wrong when a send is rejected. + */ +const optionsCache = new Map() + +/** + * The agent-settings-changed signal, as a subscribable store. + * + * Settings live in their own window, so a provider or API-key change reaches + * the composer only over this event (the backend emits it on every Hermes + * config write). ONE module-level subscription serves every mounted composer — + * the per-consumer subscription this would otherwise need is exactly the + * duplicate-fetch problem `use-acp-agents` had to coalesce away. + */ +const agentsUpdatedEvent = (() => { + const ACP_AGENTS_UPDATED_EVENT = "app://acp-agents-updated" + const listeners = new Set<() => void>() + let started = false + let generation = 0 + const start = () => { + if (started) return + started = true + void subscribe(ACP_AGENTS_UPDATED_EVENT, () => { + optionsCache.clear() + generation += 1 + for (const listener of listeners) listener() + }) + } + return { + getGeneration: () => generation, + subscribe(listener: () => void) { + start() + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + } +})() + +/** + * The composer's model picker for Hermes agents — the built-in entry and every + * custom profile registered against Hermes' own CLI. + * + * WHY this exists at all: every other agent's model picker comes from the ACP + * `configOptions` its agent advertises. Hermes advertises none — its model + * lives in `model.default` of the profile's own `config.yaml`, read once at + * process start — so the composer showed no model control for it. This reads + * that file, lists the provider's catalogue, and synthesizes the option the + * composer already knows how to render. + * + * `selectModel` writes the file and then reconnects to apply it, because a + * running Hermes process cannot be re-pointed at another model. The reconnect + * waits for the current turn to finish — cutting a turn off mid-answer to + * change a setting would lose the answer. + */ +export function useHermesModelOption(args: { + agentType: AgentType + /** The agent's own ACP options. An agent that advertises a model selector + * owns it; this hook then contributes nothing. */ + configOptions: SessionConfigOptionInfo[] + /** Live connection status, used to defer the reconnect past a running turn. */ + status: ConnectionStatus | null + /** Reconnects the session so the agent re-reads its config (`reapplyConfig`). */ + reapplyConfig: () => Promise + /** False for viewers and delegation children — they don't own the process, + * so reconnecting isn't theirs to do (mirrors the stale-config banner). */ + canReconnect: boolean +}): { + /** The synthetic option to merge into the composer's list, or null. */ + option: SessionConfigOptionInfo | null + /** Handles a pick on `HERMES_MODEL_CONFIG_ID`. */ + selectModel: (model: string) => void +} { + const { agentType, configOptions, status, reapplyConfig, canReconnect } = args + const t = useTranslations("Folder.chat.hermesModel") + // Bumped when agent settings change; re-runs the lookup below against the + // newly selected provider. + const generation = useSyncExternalStore( + agentsUpdatedEvent.subscribe, + agentsUpdatedEvent.getGeneration, + agentsUpdatedEvent.getGeneration + ) + // Stamped with the agent it describes, and read back through that stamp, so + // retargeting a tab to another agent can never show the previous agent's + // model for a frame — and needs no state reset on the way through. + const [stored, setStored] = useState<{ + agentType: AgentType + generation: number + options: HermesModelOptions + } | null>(null) + // The stamp carries the generation too: a settings change must not leave the + // previous provider's catalogue on screen while the new one is being fetched. + const options = + stored?.agentType === agentType && stored.generation === generation + ? stored.options + : (optionsCache.get(agentType) ?? null) + + // An agent that advertises its own model selector owns it — never shadow it. + const agentOwnsModelOption = configOptions.some(isModelConfigOption) + + useEffect(() => { + if (agentOwnsModelOption) return + if (optionsCache.has(agentType)) return + let cancelled = false + acpHermesModelOptions(agentType) + .then((result) => { + if (cancelled || !result) return + optionsCache.set(agentType, result) + setStored({ agentType, generation, options: result }) + }) + .catch((e: unknown) => { + // A lookup failure costs a picker, never a session — the composer keeps + // rendering everything else. + console.error("[HermesModel] options:", e) + }) + return () => { + cancelled = true + } + }, [agentType, agentOwnsModelOption, generation]) + + // A reconnect requested while a turn was in flight, applied once it settles. + const pendingReconnectRef = useRef(false) + const reapplyConfigRef = useRef(reapplyConfig) + useEffect(() => { + reapplyConfigRef.current = reapplyConfig + }, [reapplyConfig]) + // Read through a ref, never the closure: the write is async, so a turn that + // finishes while it is in flight would leave a captured "prompting" behind. + // Acting on that stale value parks the reconnect waiting for a transition + // that already happened — the new model would then sit unapplied until the + // user happened to run another turn. + const statusRef = useRef(status) + useEffect(() => { + statusRef.current = status + }, [status]) + + const reconnect = useCallback(() => { + reapplyConfigRef + .current() + .then((reconnected) => { + if (reconnected) toast.success(t("applied")) + }) + .catch((e: unknown) => { + toast.error(t("applyFailed"), { + description: e instanceof Error ? e.message : String(e), + }) + }) + }, [t]) + + useEffect(() => { + if (!pendingReconnectRef.current) return + if (status === "prompting") return + pendingReconnectRef.current = false + reconnect() + }, [status, reconnect]) + + const selectModel = useCallback( + (model: string) => { + const previous = optionsCache.get(agentType) ?? options + // Optimistic: the picker must show the pick immediately, or it reads as a + // dropped click during the write + reconnect. + const next: HermesModelOptions = { + current_model: model, + models: previous?.models ?? [], + error: previous?.error ?? null, + } + optionsCache.set(agentType, next) + setStored({ agentType, generation, options: next }) + + acpSetHermesModel({ agentType, model }) + .then(() => { + if (!canReconnect) return + // The file is written; a running Hermes still holds the old model. + if (statusRef.current === "prompting") { + pendingReconnectRef.current = true + toast.info(t("appliesAfterTurn")) + return + } + reconnect() + }) + .catch((e: unknown) => { + // Put the real model back — leaving the optimistic value would have + // the composer name a model the agent is not on. + if (previous) { + optionsCache.set(agentType, previous) + setStored({ agentType, generation, options: previous }) + } else { + optionsCache.delete(agentType) + setStored(null) + } + toast.error(t("saveFailed"), { + description: e instanceof Error ? e.message : String(e), + }) + }) + }, + [agentType, canReconnect, generation, options, reconnect, t] + ) + + const option = useMemo(() => { + if (agentOwnsModelOption || !options) return null + const current = options.current_model ?? "" + // The current model always appears, even when it is missing from the + // provider's list (a stale id, or a list codeg could not fetch) — a picker + // whose selected value has no row shows a blank trigger. + const values = options.models.includes(current) + ? options.models + : current + ? [current, ...options.models] + : options.models + if (values.length === 0) return null + return { + id: HERMES_MODEL_CONFIG_ID, + name: t("label"), + description: options.error ?? null, + // Drives the composer's `provider/`-prefix grouping and its searchable + // long-list picker, same as an agent-advertised model option. + category: "model", + kind: { + type: "select", + current_value: current, + options: values.map((value) => ({ value, name: value })), + groups: [], + }, + } + }, [agentOwnsModelOption, options, t]) + + return { option, selectModel } +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 21915937fc..408bfb9690 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -3770,6 +3770,13 @@ "reconnectFailed": "فشل في إعادة الاتصال بالجلسة", "applied": "تم تطبيق الإعدادات الجديدة" }, + "hermesModel": { + "label": "النموذج", + "applied": "تم تطبيق النموذج — أُعيد الاتصال بالجلسة", + "appliesAfterTurn": "تم حفظ النموذج — سيُطبَّق بعد انتهاء الدور الحالي", + "applyFailed": "تعذّر إعادة الاتصال بالجلسة", + "saveFailed": "تعذّر حفظ النموذج" + }, "piProjectTrust": { "title": "يتضمّن هذا المشروع موارد pi", "description": "لا يقوم pi بتحميل ملفات ‎.pi الخاصة بالمستودع. راجعها لتقرّر.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index d2210778af..37e7b1ebdb 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -3770,6 +3770,13 @@ "reconnectFailed": "Sitzung konnte nicht neu verbunden werden", "applied": "Neue Konfiguration angewendet" }, + "hermesModel": { + "label": "Modell", + "applied": "Modell übernommen – Sitzung neu verbunden", + "appliesAfterTurn": "Modell gespeichert – wird nach dem aktuellen Zug übernommen", + "applyFailed": "Sitzung konnte nicht neu verbunden werden", + "saveFailed": "Modell konnte nicht gespeichert werden" + }, "piProjectTrust": { "title": "Dieses Projekt enthält pi-Ressourcen", "description": "pi lädt die .pi-Dateien des Repositorys nicht. Prüfe sie, um zu entscheiden.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 9645dc1cb6..f71142e92b 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3770,6 +3770,13 @@ "reconnectFailed": "Failed to reconnect session", "applied": "New configuration applied" }, + "hermesModel": { + "label": "Model", + "applied": "Model applied — session reconnected", + "appliesAfterTurn": "Model saved — applies once the current turn finishes", + "applyFailed": "Failed to reconnect the session", + "saveFailed": "Failed to save the model" + }, "piProjectTrust": { "title": "This project ships pi resources", "description": "pi is not loading the repository's own .pi files. Review them to decide.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index f53a99a22d..45d862fd1b 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -3770,6 +3770,13 @@ "reconnectFailed": "No se pudo reconectar la sesión", "applied": "Nueva configuración aplicada" }, + "hermesModel": { + "label": "Modelo", + "applied": "Modelo aplicado: sesión reconectada", + "appliesAfterTurn": "Modelo guardado: se aplicará cuando termine el turno actual", + "applyFailed": "No se pudo reconectar la sesión", + "saveFailed": "No se pudo guardar el modelo" + }, "piProjectTrust": { "title": "Este proyecto incluye recursos de pi", "description": "pi no está cargando los archivos .pi del propio repositorio. Revísalos para decidir.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index f744b3ab26..616e055038 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -3770,6 +3770,13 @@ "reconnectFailed": "Échec de la reconnexion de la session", "applied": "Nouvelle configuration appliquée" }, + "hermesModel": { + "label": "Modèle", + "applied": "Modèle appliqué — session reconnectée", + "appliesAfterTurn": "Modèle enregistré — appliqué à la fin du tour en cours", + "applyFailed": "Échec de la reconnexion de la session", + "saveFailed": "Échec de l'enregistrement du modèle" + }, "piProjectTrust": { "title": "Ce projet fournit des ressources pi", "description": "pi ne charge pas les fichiers .pi du dépôt. Examinez-les pour décider.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 9131158b33..4050998f92 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -3770,6 +3770,13 @@ "reconnectFailed": "セッションの再接続に失敗しました", "applied": "新しい設定を適用しました" }, + "hermesModel": { + "label": "モデル", + "applied": "モデルを適用しました(セッションを再接続しました)", + "appliesAfterTurn": "モデルを保存しました。現在のターンの終了後に適用されます", + "applyFailed": "セッションの再接続に失敗しました", + "saveFailed": "モデルの保存に失敗しました" + }, "piProjectTrust": { "title": "このプロジェクトには pi リソースが含まれています", "description": "pi はリポジトリ自身の .pi ファイルを読み込んでいません。確認して判断してください。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index e80963174b..59093faace 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -3770,6 +3770,13 @@ "reconnectFailed": "세션 다시 연결 실패", "applied": "새 설정이 적용되었습니다" }, + "hermesModel": { + "label": "모델", + "applied": "모델이 적용되었습니다 — 세션을 다시 연결했습니다", + "appliesAfterTurn": "모델을 저장했습니다 — 현재 턴이 끝나면 적용됩니다", + "applyFailed": "세션을 다시 연결하지 못했습니다", + "saveFailed": "모델을 저장하지 못했습니다" + }, "piProjectTrust": { "title": "이 프로젝트에 pi 리소스가 포함되어 있습니다", "description": "pi가 저장소 자체의 .pi 파일을 불러오지 않고 있습니다. 확인 후 결정하세요.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 156eccae68..09f6d12db3 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -3770,6 +3770,13 @@ "reconnectFailed": "Falha ao reconectar a sessão", "applied": "Nova configuração aplicada" }, + "hermesModel": { + "label": "Modelo", + "applied": "Modelo aplicado — sessão reconectada", + "appliesAfterTurn": "Modelo salvo — será aplicado quando o turno atual terminar", + "applyFailed": "Falha ao reconectar a sessão", + "saveFailed": "Falha ao salvar o modelo" + }, "piProjectTrust": { "title": "Este projeto inclui recursos do pi", "description": "O pi não está carregando os arquivos .pi do próprio repositório. Revise-os para decidir.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b1969f16df..349d50b3e7 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3770,6 +3770,13 @@ "reconnectFailed": "重连会话失败", "applied": "新配置已应用" }, + "hermesModel": { + "label": "模型", + "applied": "模型已生效 —— 会话已重连", + "appliesAfterTurn": "模型已保存 —— 当前轮次结束后自动生效", + "applyFailed": "会话重连失败", + "saveFailed": "模型保存失败" + }, "piProjectTrust": { "title": "该项目自带 pi 资源", "description": "pi 未加载仓库自带的 .pi 文件。请查看后决定。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index d2123bd033..7833004282 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -3770,6 +3770,13 @@ "reconnectFailed": "重新連線工作階段失敗", "applied": "已套用新設定" }, + "hermesModel": { + "label": "模型", + "applied": "模型已生效 —— 工作階段已重新連線", + "appliesAfterTurn": "模型已儲存 —— 目前回合結束後自動生效", + "applyFailed": "工作階段重新連線失敗", + "saveFailed": "模型儲存失敗" + }, "piProjectTrust": { "title": "此專案自帶 pi 資源", "description": "pi 未載入儲存庫自帶的 .pi 檔案。請檢視後決定。", diff --git a/src/lib/api.ts b/src/lib/api.ts index e8e62b3fc8..de3a57934d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -75,6 +75,7 @@ import type { CursorStructuredConfig, CursorAuthStatus, CursorModelsResult, + HermesModelOptions, QoderAuthStatus, CodexModelInfo, AgentSkillScope, @@ -710,6 +711,35 @@ export async function acpUpdateHermesConfig(params: { }) } +/** + * Read a Hermes agent's current model and list the models its provider offers, + * for the composer's model picker. Returns `null` when `agentType` is not a + * Hermes agent (the composer asks this of whatever agent it is bound to), and a + * populated `error` when the listing itself failed — the current model is still + * reported in that case so the picker never claims the wrong one. + */ +export async function acpHermesModelOptions( + agentType: AgentType +): Promise { + return getTransport().call("acp_hermes_model_options", { agentType }) +} + +/** + * Point a Hermes agent at `model` by rewriting `model.default` in its own + * config.yaml. Hermes reads that file at process start, so this only reaches a + * session on its next connect; the returned count is how many running sessions + * of that agent are now on stale (launch-time) config. + */ +export async function acpSetHermesModel(params: { + agentType: AgentType + model: string +}): Promise { + return getTransport().call("acp_set_hermes_model", { + agentType: params.agentType, + model: params.model, + }) +} + /** * Persist a Kimi Code config update, keeping exactly one source authoritative. * `mode` "apikey" writes the codeg-managed ~/.kimi-code/config.toml provider/model diff --git a/src/lib/types.ts b/src/lib/types.ts index aa31b36534..a851559207 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1254,6 +1254,23 @@ export interface HermesLocalConfig { modelCommand?: string } +/** + * What the composer's Hermes model picker binds to. Mirrors the Rust + * `HermesModelOptions` (commands/acp.rs). + * + * Hermes does not advertise a model selector over ACP — its model lives in + * `model.default` of the profile's own `config.yaml` — so codeg reads it from + * disk and lists the provider's catalogue itself. + */ +export interface HermesModelOptions { + /** `model.default` from the agent's config.yaml. */ + current_model: string | null + /** Model ids the provider reports, sorted. Empty when `error` is set. */ + models: string[] + /** Why the listing failed, shown inline in the picker. Null on success. */ + error: string | null +} + export const AGENT_LABELS: Record = { claude_code: "Claude Code", codex: "Codex",