From 06aa8e681bf1924d10c13ef95690f5be18429295 Mon Sep 17 00:00:00 2001 From: Grafton <143531040+satsdisco@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:39:23 +0200 Subject: [PATCH 1/4] Add Kimi Code runtime support Co-authored-by: Grafton <143531040+satsdisco@users.noreply.github.com> Signed-off-by: Grafton <143531040+satsdisco@users.noreply.github.com> --- .../src/managed_agents/config_bridge/kimi.rs | 216 ++++++++++++++++++ .../src/managed_agents/config_bridge/mod.rs | 1 + .../managed_agents/config_bridge/reader.rs | 4 + .../src-tauri/src/managed_agents/discovery.rs | 35 ++- .../src/managed_agents/discovery/tests.rs | 2 +- .../managed_agents/discovery/tests/kimi.rs | 21 ++ .../agents/ui/agentConfigOptions.test.mjs | 3 +- .../features/agents/ui/agentConfigOptions.tsx | 3 +- .../agents/ui/personaRuntimeModel.test.mjs | 1 + .../features/agents/ui/personaRuntimeModel.ts | 4 +- 10 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/kimi.rs create mode 100644 desktop/src-tauri/src/managed_agents/discovery/tests/kimi.rs diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/kimi.rs b/desktop/src-tauri/src/managed_agents/config_bridge/kimi.rs new file mode 100644 index 0000000000..37c56116be --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/kimi.rs @@ -0,0 +1,216 @@ +use std::path::PathBuf; + +use super::types::{ExtensionEntry, RuntimeFileConfig}; + +/// Read Kimi Code config from `~/.kimi-code/config.toml` (or `$KIMI_CODE_HOME/config.toml`). +pub(super) fn read_config_file() -> Option { + let path = kimi_config_path()?; + let raw = std::fs::read_to_string(path).ok()?; + parse_kimi_config(&raw).map(|mut cfg| { + cfg.extensions = read_mcp_config(); + cfg + }) +} + +fn parse_kimi_config(toml_str: &str) -> Option { + let table: toml::Table = toml_str.parse().ok()?; + + let model = toml_string(&table, "default_model"); + let mut provider = None; + let mut context_limit = None; + + if let Some(model_id) = model.as_deref() { + if let Some(models) = table.get("models").and_then(|v| v.as_table()) { + if let Some(model_table) = models.get(model_id).and_then(|v| v.as_table()) { + provider = toml_table_string(model_table, "provider"); + context_limit = toml_table_scalar_string(model_table, "max_context_size"); + } + } + } + + let config_json = toml_to_json(&toml::Value::Table(table)); + let skip = &[ + "default_model", + "models", + "providers", + "permission", + "permissions", + "mcp", + ]; + let mut extra = super::schema_walker::extract_config_fields(&config_json, skip); + + if let Some(serde_json::Value::Object(providers)) = config_json.get("providers") { + for (name, provider_config) in providers { + extra.insert(format!("providers.{name}"), "configured".to_string()); + if provider.is_none() { + if let Some(kind) = provider_config.get("type").and_then(|v| v.as_str()) { + provider = Some(kind.to_string()); + } + } + } + } + + if let Some(serde_json::Value::Object(models)) = config_json.get("models") { + for (name, _) in models { + extra.insert(format!("models.{name}"), "configured".to_string()); + } + } + + Some(RuntimeFileConfig { + model, + provider, + mode: None, + thinking_effort: None, + max_output_tokens: None, + context_limit, + system_prompt: None, + extensions: Vec::new(), + extra, + }) +} + +fn read_mcp_config() -> Vec { + let Some(path) = kimi_mcp_config_path() else { + return Vec::new(); + }; + let Some(raw) = std::fs::read_to_string(path).ok() else { + return Vec::new(); + }; + let Some(json) = serde_json::from_str::(&raw).ok() else { + return Vec::new(); + }; + + let servers = json + .get("mcpServers") + .or_else(|| json.get("mcp_servers")) + .and_then(|v| v.as_object()); + let Some(servers) = servers else { + return Vec::new(); + }; + + servers + .keys() + .map(|name| ExtensionEntry { + name: name.clone(), + kind: "mcp".to_string(), + enabled: true, + }) + .collect() +} + +fn toml_string(table: &toml::Table, key: &str) -> Option { + table + .get(key)? + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn toml_table_string(table: &toml::value::Table, key: &str) -> Option { + table + .get(key)? + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn toml_table_scalar_string(table: &toml::value::Table, key: &str) -> Option { + match table.get(key)? { + toml::Value::String(s) => { + let trimmed = s.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + toml::Value::Integer(i) => Some(i.to_string()), + toml::Value::Float(f) => Some(f.to_string()), + _ => None, + } +} + +fn toml_to_json(val: &toml::Value) -> serde_json::Value { + match val { + toml::Value::String(s) => serde_json::Value::String(s.clone()), + toml::Value::Integer(i) => serde_json::Value::Number((*i).into()), + toml::Value::Float(f) => serde_json::Number::from_f64(*f) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + toml::Value::Boolean(b) => serde_json::Value::Bool(*b), + toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()), + toml::Value::Array(arr) => serde_json::Value::Array(arr.iter().map(toml_to_json).collect()), + toml::Value::Table(tbl) => { + let map = tbl + .iter() + .map(|(k, v)| (k.clone(), toml_to_json(v))) + .collect(); + serde_json::Value::Object(map) + } + } +} + +pub(crate) fn kimi_config_path() -> Option { + Some(kimi_home_dir()?.join("config.toml")) +} + +pub(crate) fn kimi_mcp_config_path() -> Option { + Some(kimi_home_dir()?.join("mcp.json")) +} + +fn kimi_home_dir() -> Option { + if let Ok(home) = std::env::var("KIMI_CODE_HOME") { + return Some(PathBuf::from(home)); + } + let home = dirs::home_dir()?; + Some(home.join(".kimi-code")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_default_model_provider_and_context() { + let toml = r#" +default_model = "kimi-code/kimi-for-coding" + +[models."kimi-code/kimi-for-coding"] +provider = "kimi-code" +model = "kimi-for-coding" +max_context_size = 200000 + +[providers.kimi-code] +type = "kimi" +base_url = "https://api.kimi.com/coding/v1" +"#; + let cfg = parse_kimi_config(toml).unwrap(); + assert_eq!(cfg.model.as_deref(), Some("kimi-code/kimi-for-coding")); + assert_eq!(cfg.provider.as_deref(), Some("kimi-code")); + assert_eq!(cfg.context_limit.as_deref(), Some("200000")); + assert_eq!( + cfg.extra.get("providers.kimi-code").map(String::as_str), + Some("configured") + ); + assert_eq!( + cfg.extra + .get("models.kimi-code/kimi-for-coding") + .map(String::as_str), + Some("configured") + ); + } + + #[test] + fn parse_provider_type_fallback_when_default_model_missing() { + let toml = r#" +[providers.kimi-code] +type = "kimi" +"#; + let cfg = parse_kimi_config(toml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("kimi")); + assert!(cfg.model.is_none()); + } + + #[test] + fn invalid_toml_returns_none() { + assert!(parse_kimi_config("{{{{not valid").is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs index f8b045fc72..7707439dc4 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs @@ -2,6 +2,7 @@ mod buzz_agent; mod claude; mod codex; mod goose; +mod kimi; pub(crate) mod reader; mod schema_walker; pub(crate) mod types; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 372d2cfde1..82f8b1feb3 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -22,6 +22,7 @@ pub(crate) fn read_config_surface( "goose" => super::goose::read_config_file().map(|c| (c, true)), "claude" => super::claude::read_config_file().map(|c| (c, true)), "codex" => super::codex::read_config_file().map(|c| (c, true)), + "kimi" => super::kimi::read_config_file().map(|c| (c, true)), "buzz-agent" => super::buzz_agent::read_config_file().map(|c| (c, true)), _ => None, }) @@ -222,6 +223,9 @@ fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option "codex" => { super::codex::codex_config_path().map(|path| path.to_string_lossy().into_owned()) } + "kimi" => { + super::kimi::kimi_mcp_config_path().map(|path| path.to_string_lossy().into_owned()) + } _ => None, } } diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 50206567ad..5f88ae88b3 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -16,6 +16,7 @@ pub(crate) use runtime_metadata::KnownAcpRuntime; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; +const KIMI_CODE_AVATAR_URL: &str = "https://github.com/MoonshotAI.png"; const BUZZ_AGENT_AVATAR_URL: &str = "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png"; @@ -36,6 +37,7 @@ fn common_binary_paths() -> &'static [PathBuf] { } if let Some(home) = dirs::home_dir() { paths.extend([ + home.join(".kimi-code").join("bin"), home.join(".local/share/mise/shims"), home.join(".local/bin"), home.join(".volta/bin"), @@ -157,6 +159,37 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), }, + KnownAcpRuntime { + id: "kimi", + label: "Kimi Code", + commands: &["kimi"], + aliases: &["kimi-code", "kimicode"], + avatar_url: KIMI_CODE_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("kimi"), + cli_install_commands: &["curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash"], + cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://code.kimi.com/kimi-code/install.ps1 | iex\""], + adapter_install_commands: &[], + install_instructions_url: "https://www.kimi.com/code/docs/en/kimi-code-cli/reference/kimi-acp", + cli_install_hint: "Install the Kimi Code CLI via the official install script.", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: true, + model_env_var: None, + provider_env_var: None, + provider_locked: true, + default_env: &[], + config_file_path: Some("~/.kimi-code/config.toml"), + config_file_format: Some("toml"), + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run `kimi login` to authenticate."), + auth_probe_args: None, + }, KnownAcpRuntime { id: "buzz-agent", label: "Buzz Agent", @@ -342,7 +375,7 @@ pub use overrides::{apply_agent_command_update, create_time_agent_command_overri fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { - "goose" => Some(vec!["acp".to_string()]), + "goose" | "kimi" | "kimi-code" | "kimicode" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 0ed4fe0f6a..e125a52567 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -11,7 +11,7 @@ use super::{ GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; - +mod kimi; #[test] fn resolves_known_avatar_for_bare_command() { let avatar_url = managed_agent_avatar_url("goose").expect("goose avatar should resolve"); diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/kimi.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/kimi.rs new file mode 100644 index 0000000000..9755cffc4e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/kimi.rs @@ -0,0 +1,21 @@ +use super::super::{managed_agent_avatar_url, normalize_agent_args, KIMI_CODE_AVATAR_URL}; + +#[test] +fn resolves_kimi_avatar() { + assert_eq!( + managed_agent_avatar_url("/usr/local/bin/kimi"), + Some(KIMI_CODE_AVATAR_URL.to_string()) + ); +} + +#[test] +fn normalizes_kimi_args_to_acp() { + assert_eq!( + normalize_agent_args("kimi", Vec::new()), + vec!["acp".to_string()] + ); + assert_eq!( + normalize_agent_args("kimi-code", Vec::new()), + vec!["acp".to_string()] + ); +} diff --git a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs index d0690cefc3..428dfff09e 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs @@ -141,9 +141,10 @@ test("runtimeSupportsLlmProviderSelection is true for buzz-agent and goose", () assert.equal(runtimeSupportsLlmProviderSelection("goose"), true); }); -test("runtimeSupportsLlmProviderSelection is false for codex and claude", () => { +test("runtimeSupportsLlmProviderSelection is false for codex, claude, and kimi", () => { assert.equal(runtimeSupportsLlmProviderSelection("codex"), false); assert.equal(runtimeSupportsLlmProviderSelection("claude"), false); + assert.equal(runtimeSupportsLlmProviderSelection("kimi"), false); }); test("resetConfigForHarnessChange clears harness-specific values", () => { diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 6ae81ff6cb..f6220a609f 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -133,6 +133,7 @@ const PERSONA_MODEL_OPTIONS_BY_RUNTIME: Record< "buzz-agent": [DEFAULT_MODEL_OPTION], claude: [DEFAULT_MODEL_OPTION], codex: [DEFAULT_MODEL_OPTION], + kimi: [DEFAULT_MODEL_OPTION], }; export function getRuntimePersonaModelOptions( @@ -151,7 +152,7 @@ function isKnownLlmProvider( * Required credential env keys for the given runtime + provider combination. * Derived from PROVIDER_CREDENTIAL_CONFIG — single source of truth. * - * buzz-agent and goose use provider-specific credentials; claude and codex + * buzz-agent and goose use provider-specific credentials; claude, codex, and kimi * handle auth via CLI login (surfaced separately via the CliLogin surface). */ export function requiredCredentialEnvKeys( diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs b/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs index 967bd3e129..efcf6af020 100644 --- a/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs +++ b/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs @@ -195,6 +195,7 @@ test("resolveRuntimeProviderCapability classifies known CLI-login runtimes as lo // The core fix: a not-yet-loaded catalog must not force these to "unknown". assert.equal(resolveRuntimeProviderCapability("claude", false), "locked"); assert.equal(resolveRuntimeProviderCapability("codex", false), "locked"); + assert.equal(resolveRuntimeProviderCapability("kimi", false), "locked"); assert.equal(resolveRuntimeProviderCapability(" claude ", false), "locked"); }); diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.ts b/desktop/src/features/agents/ui/personaRuntimeModel.ts index d8da4108c9..8d39ac8c01 100644 --- a/desktop/src/features/agents/ui/personaRuntimeModel.ts +++ b/desktop/src/features/agents/ui/personaRuntimeModel.ts @@ -18,7 +18,7 @@ export type ProviderRuntimeCapability = "capable" | "locked" | "unknown"; * provider. To avoid that, we resolve capability STATICALLY for known ids: * * - buzz-agent / goose → "capable" (`isProviderCapable`, id-based). - * - claude / codex → "locked" (CLI-login runtimes; no LLM provider selection). + * - claude / codex / kimi → "locked" (CLI-login runtimes; no LLM provider selection). * - anything else (custom, empty, genuinely unknown) → "unknown". * * `isProviderCapable` is the caller-supplied {@link @@ -33,7 +33,7 @@ export function resolveRuntimeProviderCapability( return "capable"; } const id = runtimeId.trim(); - if (id === "claude" || id === "codex") { + if (id === "claude" || id === "codex" || id === "kimi") { return "locked"; } return "unknown"; From 3b49c10c18fb53dc4ecb4826d561f80670106e3a Mon Sep 17 00:00:00 2001 From: Grafton <143531040+satsdisco@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:51:46 +0200 Subject: [PATCH 2/4] Allow shared agents in channel mentions Co-authored-by: Grafton <143531040+satsdisco@users.noreply.github.com> Signed-off-by: Grafton <143531040+satsdisco@users.noreply.github.com> --- .../lib/agentAutocompleteEligibility.test.mjs | 69 ++++++++++++++++++- .../lib/agentAutocompleteEligibility.ts | 29 ++++++-- .../src/features/messages/lib/useMentions.ts | 2 + 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd68..92f4826def 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -77,6 +77,29 @@ test("relayAgentIsSharedWithUser: accepts shared anyone agents and rejects unsha ); }); +test("relayAgentIsSharedWithUser: active channel must match the agent channel", () => { + const sharedChannelIds = new Set(["general", "ops"]); + + assert.equal( + relayAgentIsSharedWithUser( + { respondTo: "anyone", respondToAllowlist: [], channelIds: ["general"] }, + sharedChannelIds, + CURRENT_PUBKEY, + "general", + ), + true, + ); + assert.equal( + relayAgentIsSharedWithUser( + { respondTo: "anyone", respondToAllowlist: [], channelIds: ["ops"] }, + sharedChannelIds, + CURRENT_PUBKEY, + "general", + ), + false, + ); +}); + test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user", () => { const sharedChannelIds = new Set(["general"]); @@ -85,7 +108,7 @@ test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user" { respondTo: "allowlist", respondToAllowlist: [OTHER_OWNER_PUBKEY, CURRENT_PUBKEY.toUpperCase()], - channelIds: ["other"], + channelIds: ["general"], }, sharedChannelIds, CURRENT_PUBKEY, @@ -106,6 +129,23 @@ test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user" ); }); +test("relayAgentIsSharedWithUser: allowlist agents still require shared channel placement", () => { + const sharedChannelIds = new Set(["general"]); + + assert.equal( + relayAgentIsSharedWithUser( + { + respondTo: "allowlist", + respondToAllowlist: [CURRENT_PUBKEY], + channelIds: ["other"], + }, + sharedChannelIds, + CURRENT_PUBKEY, + ), + false, + ); +}); + test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", () => { const result = getMentionableAgentPubkeys({ managedAgentPubkeys: [PUB_A], @@ -121,7 +161,7 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", pubkey: PUB_C, respondTo: "allowlist", respondToAllowlist: [CURRENT_PUBKEY], - channelIds: ["other"], + channelIds: ["general"], }, { pubkey: PUB_D, @@ -136,6 +176,31 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C])); }); +test("getMentionableAgentPubkeys: scopes relay agents to the active channel", () => { + const result = getMentionableAgentPubkeys({ + managedAgentPubkeys: [PUB_A], + currentPubkey: CURRENT_PUBKEY, + relayAgents: [ + { + pubkey: PUB_B, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: ["general"], + }, + { + pubkey: PUB_C, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: ["ops"], + }, + ], + sharedChannelIds: new Set(["general", "ops"]), + activeChannelId: "general", + }); + + assert.deepEqual(result, new Set([PUB_A, PUB_B])); +}); + test("isAgentIdentityInManagedList: keeps people and only current managed agent identities", () => { const managedAgentPubkeys = new Set([PUB_A]); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e4afe7fea4..ae614b217c 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -13,10 +13,23 @@ export function relayAgentIsSharedWithUser( agent: Pick, sharedChannelIds: ReadonlySet, currentPubkey?: string | null, + activeChannelId?: string | null, ) { const normalizedCurrentPubkey = currentPubkey ? normalizePubkey(currentPubkey) : null; + const sharesActiveChannel = + activeChannelId == null || agent.channelIds.includes(activeChannelId); + if (!sharesActiveChannel) { + return false; + } + + const sharesAnyJoinedChannel = agent.channelIds.some((channelId) => + sharedChannelIds.has(channelId), + ); + if (!sharesAnyJoinedChannel) { + return false; + } if (agent.respondTo === "allowlist" && normalizedCurrentPubkey) { return agent.respondToAllowlist @@ -24,10 +37,7 @@ export function relayAgentIsSharedWithUser( .includes(normalizedCurrentPubkey); } - return ( - agent.respondTo === "anyone" && - agent.channelIds.some((channelId) => sharedChannelIds.has(channelId)) - ); + return agent.respondTo === "anyone"; } export function getMentionableAgentPubkeys({ @@ -35,18 +45,27 @@ export function getMentionableAgentPubkeys({ managedAgentPubkeys, relayAgents, sharedChannelIds, + activeChannelId, }: { currentPubkey?: string | null; managedAgentPubkeys: Iterable; relayAgents: readonly RelayAgent[] | undefined; sharedChannelIds: ReadonlySet; + activeChannelId?: string | null; }) { const pubkeys = new Set( [...managedAgentPubkeys].map((pubkey) => normalizePubkey(pubkey)), ); for (const agent of relayAgents ?? []) { - if (relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)) { + if ( + relayAgentIsSharedWithUser( + agent, + sharedChannelIds, + currentPubkey, + activeChannelId, + ) + ) { pubkeys.add(normalizePubkey(agent.pubkey)); } } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..dc8daf48cb 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -199,8 +199,10 @@ export function useMentions( managedAgentPubkeys, relayAgents: relayAgentsQuery.data, sharedChannelIds, + activeChannelId: channelId, }), [ + channelId, currentPubkey, managedAgentPubkeys, relayAgentsQuery.data, From 1f107dbfd0fe9726f7463a32ba58657bd5cf6d84 Mon Sep 17 00:00:00 2001 From: Grafton <143531040+satsdisco@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:24:30 +0200 Subject: [PATCH 3/4] fix(desktop): show remote channel agents in mentions Co-authored-by: Grafton <143531040+satsdisco@users.noreply.github.com> Signed-off-by: Grafton <143531040+satsdisco@users.noreply.github.com> --- .../lib/agentAutocompleteEligibility.test.mjs | 11 +++++++++++ .../agents/lib/agentAutocompleteEligibility.ts | 5 ++++- desktop/src/features/messages/lib/useMentions.ts | 13 ++++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 92f4826def..882f264972 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -227,6 +227,17 @@ test("isAgentIdentityInManagedList: keeps people and only current managed agent ); }); +test("isAgentIdentityInManagedList: keeps remote agents explicitly allowed by the caller", () => { + assert.equal( + isAgentIdentityInManagedList( + { isAgent: true, pubkey: PUB_B.toUpperCase() }, + new Set([PUB_A]), + new Set([PUB_B]), + ), + true, + ); +}); + test("shouldHideAgentFromMentions: never hides non-agents", () => { assert.equal( shouldHideAgentFromMentions({ diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index ae614b217c..64e6f6a247 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -76,10 +76,13 @@ export function getMentionableAgentPubkeys({ export function isAgentIdentityInManagedList( candidate: { isAgent?: boolean; pubkey: string }, managedAgentPubkeys: ReadonlySet, + allowedAgentPubkeys: ReadonlySet = new Set(), ) { + const normalizedPubkey = normalizePubkey(candidate.pubkey); return ( candidate.isAgent !== true || - managedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) + managedAgentPubkeys.has(normalizedPubkey) || + allowedAgentPubkeys.has(normalizedPubkey) ); } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index dc8daf48cb..97290349ca 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -240,6 +240,10 @@ export function useMentions( new Set((members ?? []).map((member) => normalizePubkey(member.pubkey))), [members], ); + const allowedAgentIdentityPubkeys = React.useMemo( + () => new Set([...memberPubkeys, ...mentionableAgentPubkeys]), + [memberPubkeys, mentionableAgentPubkeys], + ); const mentionCandidates = React.useMemo(() => { const candidatesByPubkey = new Map(); @@ -248,7 +252,13 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + if ( + !isAgentIdentityInManagedList( + candidate, + managedAgentPubkeys, + allowedAgentIdentityPubkeys, + ) + ) { return; } if ( @@ -414,6 +424,7 @@ export function useMentions( }, [ activePersonaById, activePersonas, + allowedAgentIdentityPubkeys, userSearchResults, canSearchGlobalUsers, currentPubkey, From 1ade74bb88182d95293a4aac7241f7b5d0029d4c Mon Sep 17 00:00:00 2001 From: Grafton <143531040+satsdisco@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:59:22 +0200 Subject: [PATCH 4/4] fix(desktop): allow channel member agents in mentions Co-authored-by: Grafton <143531040+satsdisco@users.noreply.github.com> Signed-off-by: Grafton <143531040+satsdisco@users.noreply.github.com> --- .../lib/agentAutocompleteEligibility.test.mjs | 25 ++--------------- .../lib/agentAutocompleteEligibility.ts | 19 +++---------- .../src/features/messages/lib/useMentions.ts | 27 ++++++++++--------- 3 files changed, 21 insertions(+), 50 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 882f264972..98924cf753 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -245,7 +245,6 @@ test("shouldHideAgentFromMentions: never hides non-agents", () => { isMember: false, pubkey: PUB_A, mentionableAgentPubkeys: new Set(), - directoryAgentPubkeys: new Set([PUB_A]), }), false, ); @@ -258,7 +257,6 @@ test("shouldHideAgentFromMentions: shows invocable agents even when non-member", isMember: false, pubkey: PUB_A, mentionableAgentPubkeys: new Set([PUB_A]), - directoryAgentPubkeys: new Set([PUB_A]), }), false, ); @@ -271,22 +269,20 @@ test("shouldHideAgentFromMentions: hides non-member non-invocable agents", () => isMember: false, pubkey: PUB_A, mentionableAgentPubkeys: new Set(), - directoryAgentPubkeys: new Set(), }), true, ); }); -test("shouldHideAgentFromMentions: hides member agents with an explicit not-invocable directory entry (Fizz)", () => { +test("shouldHideAgentFromMentions: shows member agents even with a non-invocable directory entry", () => { assert.equal( shouldHideAgentFromMentions({ isAgent: true, isMember: true, pubkey: PUB_A, mentionableAgentPubkeys: new Set(), - directoryAgentPubkeys: new Set([PUB_A]), }), - true, + false, ); }); @@ -297,28 +293,11 @@ test("shouldHideAgentFromMentions: shows member agents with unknown invocability isMember: true, pubkey: PUB_A, mentionableAgentPubkeys: new Set(), - directoryAgentPubkeys: new Set(), }), false, ); }); -test("shouldHideAgentFromMentions: normalizes the pubkey before lookup", () => { - const mixedCase = "Ab".repeat(32); - const normalized = mixedCase.toLowerCase(); - - assert.equal( - shouldHideAgentFromMentions({ - isAgent: true, - isMember: true, - pubkey: mixedCase, - mentionableAgentPubkeys: new Set(), - directoryAgentPubkeys: new Set([normalized]), - }), - true, - ); -}); - test("coalesceAgentAutocompleteCandidates: merges agents with the same persona id", () => { const first = makeAgent({ pubkey: PUB_A, personaId: "pinky" }); const second = makeAgent({ diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 64e6f6a247..d17d8e770d 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -91,32 +91,21 @@ export function shouldHideAgentFromMentions({ isMember, pubkey, mentionableAgentPubkeys, - directoryAgentPubkeys, }: { isAgent: boolean; isMember: boolean; pubkey: string; mentionableAgentPubkeys: ReadonlySet; - directoryAgentPubkeys: ReadonlySet; }) { if (!isAgent) return false; const normalized = normalizePubkey(pubkey); // Invocable => always show. if (mentionableAgentPubkeys.has(normalized)) return false; + // Channel membership is enough to expose the identity as mentionable. The + // receiving agent still enforces whether it will respond. + if (isMember) return false; // Non-member, non-invocable => hide (preserves prior behavior). - if (!isMember) return true; - // Member (Option B): hide only when we have an explicit not-invocable - // signal — a relay directory (kind:10100) entry that excludes us. - // Unknown invocability (not in directory) => show. - // - // NOTE: this assumes `directoryAgentPubkeys` and `mentionableAgentPubkeys` - // share the same source query (`relayAgentsQuery.data`), so directory - // presence without membership in `mentionableAgentPubkeys` is a real - // explicit-exclusion signal. If a future change sources the directory set - // from a different query, an agent that's directory-present but whose - // mentionability is still loading could be hidden prematurely — keep the - // two sets derived from the same query. - return directoryAgentPubkeys.has(normalized); + return true; } type AgentAutocompleteCandidate = { diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 97290349ca..f71dab1818 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -179,15 +179,6 @@ export function useMentions( ), [relayAgentsQuery.data], ); - const directoryAgentPubkeys = React.useMemo( - () => - new Set( - (relayAgentsQuery.data ?? []).map((agent) => - normalizePubkey(agent.pubkey), - ), - ), - [relayAgentsQuery.data], - ); const sharedChannelIds = React.useMemo( () => getSharedChannelIds(channelsQuery.data), [channelsQuery.data], @@ -222,7 +213,6 @@ export function useMentions( } return lookup; }, [managedAgentsQuery.data, personasQuery.data]); - const knownAgentPubkeys = mentionableAgentPubkeys; const activePersonas = React.useMemo( () => (personasQuery.data ?? []).filter((persona) => persona.isActive), [personasQuery.data], @@ -240,6 +230,21 @@ export function useMentions( new Set((members ?? []).map((member) => normalizePubkey(member.pubkey))), [members], ); + const memberAgentPubkeys = React.useMemo( + () => + new Set( + (members ?? []) + .filter( + (member) => member.isAgent === true || member.role === "bot", + ) + .map((member) => normalizePubkey(member.pubkey)), + ), + [members], + ); + const knownAgentPubkeys = React.useMemo( + () => new Set([...mentionableAgentPubkeys, ...memberAgentPubkeys]), + [memberAgentPubkeys, mentionableAgentPubkeys], + ); const allowedAgentIdentityPubkeys = React.useMemo( () => new Set([...memberPubkeys, ...mentionableAgentPubkeys]), [memberPubkeys, mentionableAgentPubkeys], @@ -267,7 +272,6 @@ export function useMentions( isMember: candidate.isMember === true, pubkey, mentionableAgentPubkeys, - directoryAgentPubkeys, }) ) { return; @@ -428,7 +432,6 @@ export function useMentions( userSearchResults, canSearchGlobalUsers, currentPubkey, - directoryAgentPubkeys, isArchivedDiscovery, managedAgentNamesByPubkey, managedAgentPersonaIds,