From 1c1738298ba954a9c0a319e0309dca2bd3fa33db Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sun, 12 Apr 2026 03:36:08 +0800 Subject: [PATCH] fix(acp): harden session-page connection and localize backend errors - Session-page connect never triggers download/install; returns SdkNotInstalled immediately and prompts the user to install from Agent Settings instead - Binary agents now accept any cached version via find_best_cached_binary_for_agent so stale caches still connect - Bound Initialize handshake with a 60s timeout and convert it to AcpError::InitializeTimeout via a sentinel in run_connection - Spawn background task owns ConnectionManager map insertion and removes the entry on exit through an RAII guard that survives panics, preventing leaked stale entries - AcpError gains SdkNotInstalled and InitializeTimeout variants plus a stable code() identifier; AcpEvent::Error carries code so the frontend can render localized messages by key - Frontend preflight now runs for all connect sources; error event handler switches on code to show translated text for initialize_timeout, sdk_not_installed, platform_not_supported, process_exited, spawn_failed and download_failed - Remove ConnectionStatus::Downloading enum variant, all frontend branches, and i18n strings; drop obsolete autoLinkFailedTitle, autoLinkPreflightFailed, preflightCheckFailedDefault and preflightFailedTitle keys across 10 locales - Add backendErrors.* translations in 10 languages - Diagnostic logging: always log agent stderr plus binary path/size/args/env keys and Initialize timing; gate stdin/stdout JSON-RPC tracing behind CODEG_ACP_DEBUG to avoid persisting user content into OS log files Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/acp/binary_cache.rs | 28 ++ src-tauri/src/acp/connection.rs | 258 +++++++++++++++--- src-tauri/src/acp/error.rs | 23 ++ src-tauri/src/acp/manager.rs | 12 +- src-tauri/src/acp/preflight.rs | 69 +++-- src-tauri/src/acp/types.rs | 5 +- src-tauri/src/commands/acp.rs | 66 ++++- src-tauri/src/web/handlers/acp.rs | 15 +- src/components/chat/chat-input.tsx | 2 +- .../conversation-detail-panel.tsx | 11 +- .../layout/status-bar-connection.tsx | 5 - src/contexts/acp-connections-context.tsx | 206 ++++++++------ src/hooks/use-connection-lifecycle.ts | 18 +- src/hooks/use-connection.ts | 12 +- src/i18n/messages/ar.json | 11 +- src/i18n/messages/de.json | 11 +- src/i18n/messages/en.json | 11 +- src/i18n/messages/es.json | 11 +- src/i18n/messages/fr.json | 11 +- src/i18n/messages/ja.json | 11 +- src/i18n/messages/ko.json | 11 +- src/i18n/messages/pt.json | 11 +- src/i18n/messages/zh-CN.json | 11 +- src/i18n/messages/zh-TW.json | 11 +- src/lib/types.ts | 10 +- 25 files changed, 595 insertions(+), 255 deletions(-) diff --git a/src-tauri/src/acp/binary_cache.rs b/src-tauri/src/acp/binary_cache.rs index 5eca78b..e7a3393 100644 --- a/src-tauri/src/acp/binary_cache.rs +++ b/src-tauri/src/acp/binary_cache.rs @@ -133,6 +133,34 @@ pub fn detect_installed_version( installed_version_for_agent(agent_type, cmd_name) } +/// Return the best cached binary across all installed versions. +/// +/// This returns the path + version label of the highest semver-ish +/// version cached on disk, regardless of what the registry considers +/// the "recommended" version. The session-page connect path uses this +/// to tolerate older-but-still-usable cached binaries (e.g. the user +/// hasn't upgraded yet) — the Settings page will continue to surface +/// an "upgrade available" hint via the separate version-badge path. +/// +/// Returns Ok(None) when no usable binary is cached. +pub fn find_best_cached_binary_for_agent( + agent_type: AgentType, + cmd_name: &str, +) -> Result, AcpError> { + let agent_id = agent_cache_key(agent_type); + let mut versions = installed_version_labels(&agent_id, cmd_name)?; + if versions.is_empty() { + return Ok(None); + } + versions.sort_by(|a, b| version_cmp(a, b)); + while let Some(version) = versions.pop() { + if let Some(path) = installed_binary_path(&agent_id, &version, cmd_name) { + return Ok(Some((path, version))); + } + } + Ok(None) +} + fn version_cmp(a: &str, b: &str) -> std::cmp::Ordering { let mut a_parts = parse_version_parts(a); let mut b_parts = parse_version_parts(b); diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 10d61ba..cf97da2 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -93,6 +93,41 @@ pub enum ConnectionCommand { Disconnect, } +/// Sentinel string embedded in a `sacp::Error` when the Initialize +/// handshake times out. Converted back to `AcpError::InitializeTimeout` +/// by the outer `.map_err(...)` in `run_connection`. +const INIT_TIMEOUT_SENTINEL: &str = "__codeg_init_timeout__"; + +/// RAII guard that removes the `AgentConnection` entry from the manager +/// map when dropped. Runs on both normal task exit AND task panic, so a +/// panic inside `run_connection` can't leak a stale map entry. +/// +/// The `Mutex` is async, so we take two paths: +/// - If the lock is immediately available (`try_lock` succeeds), remove +/// the entry synchronously in the current context. +/// - Otherwise, spawn a short-lived cleanup task to acquire the lock +/// and remove the entry asynchronously. The guard must hold owned +/// `Arc>` and `String` so the spawned task has `'static` +/// captures. +struct ConnectionCleanupGuard { + connections: Arc>>, + connection_id: String, +} + +impl Drop for ConnectionCleanupGuard { + fn drop(&mut self) { + if let Ok(mut guard) = self.connections.try_lock() { + guard.remove(&self.connection_id); + return; + } + let connections = self.connections.clone(); + let connection_id = std::mem::take(&mut self.connection_id); + tokio::spawn(async move { + connections.lock().await.remove(&connection_id); + }); + } +} + /// Represents a single active ACP agent connection. pub struct AgentConnection { pub id: String, @@ -116,8 +151,6 @@ impl AgentConnection { async fn build_agent( agent_type: AgentType, runtime_env: &BTreeMap, - connection_id: &str, - emitter: &EventEmitter, ) -> Result { let meta = registry::get_agent_meta(agent_type); debug_assert_eq!(meta.agent_type, agent_type); @@ -182,14 +215,14 @@ async fn build_agent( .map_err(|e| AcpError::SpawnFailed(e.to_string())) } AgentDistribution::Binary { - version, + version: registry_version, cmd, args, env, platforms, } => { let platform = registry::current_platform(); - let info = platforms + let _ = platforms .iter() .find(|p| p.platform == platform) .ok_or_else(|| { @@ -199,33 +232,49 @@ async fn build_agent( )) })?; - let has_cached_binary = - crate::acp::binary_cache::find_cached_binary_for_agent(agent_type, version, cmd) - .ok() - .flatten() - .is_some(); - if !has_cached_binary { - crate::web::event_bridge::emit_event( - emitter, - "acp://event", - AcpEvent::StatusChanged { - connection_id: connection_id.into(), - status: ConnectionStatus::Downloading, - }, + // Session-page connect must never trigger a download. Use + // the best cached version available (tolerates users on + // older-but-still-working binaries); return SdkNotInstalled + // only when nothing is cached, so the frontend can prompt + // the user to install it from the Agent Settings page. + // + // INVARIANT: the substring "is not installed" is matched + // verbatim by the frontend catch block in + // `src/contexts/acp-connections-context.tsx` to surface a + // localized install prompt. Do not change the wording. + let (binary_path, cached_version) = + crate::acp::binary_cache::find_best_cached_binary_for_agent(agent_type, cmd)? + .ok_or_else(|| { + AcpError::SdkNotInstalled(format!( + "{} is not installed. Please install it in Agent Settings.", + meta.name + )) + })?; + if cached_version == registry_version { + eprintln!( + "[ACP][{}] Using cached binary {cached_version}", + meta.name + ); + } else { + eprintln!( + "[ACP][{}] Using cached binary {cached_version} (registry recommends {registry_version})", + meta.name ); } - let binary_path = crate::acp::binary_cache::ensure_binary_for_agent( - agent_type, version, info.url, cmd, - ) - .await?; let binary_str = binary_path.to_string_lossy().to_string(); + let binary_size = std::fs::metadata(&binary_path) + .map(|m| m.len()) + .unwrap_or(0); let mut server = McpServerStdio::new(meta.name, &binary_str); let cmd_args: Vec = args.iter().map(|a| (*a).to_string()).collect(); + let cmd_args_for_log = cmd_args.clone(); if !cmd_args.is_empty() { server = server.args(cmd_args); } let merged_env = merge_agent_env(env, runtime_env); + let env_key_list: Vec<&str> = + merged_env.iter().map(|(k, _)| k.as_str()).collect(); if !merged_env.is_empty() { let env_vars: Vec = merged_env .iter() @@ -233,11 +282,63 @@ async fn build_agent( .collect(); server = server.env(env_vars); } + // Spawn-time diagnostic dump: binary identity, args, and env + // key list (values omitted — they may contain API keys). If + // the connection hangs later, these lines pin down exactly + // which binary was invoked and how. + eprintln!( + "[ACP][{}] binary_path={} size={} platform={} args={:?} env_keys={:?}", + meta.name, + binary_str, + binary_size, + registry::current_platform(), + cmd_args_for_log, + env_key_list + ); + + // Stdio logging policy: + // - stderr is always on: it's the agent's own diagnostic + // output (ANSI log lines) and does not contain user data. + // - stdin / stdout carry JSON-RPC traffic that includes + // prompt text, tool-call arguments, file read/write + // contents, and permission-response payloads — all of + // which may contain API keys pasted by users or file + // contents the agent is editing. They are gated behind + // the `CODEG_ACP_DEBUG=1` env var so production builds + // don't persist user content into OS-level log files + // (Console.app on macOS, journald on Linux). + // - Max line length is kept short so what does get logged + // captures the JSON-RPC envelope (method, id) rather + // than large payload bodies. + let stdio_debug_enabled = std::env::var("CODEG_ACP_DEBUG") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); let agent_name = meta.name.to_string(); Ok(AcpAgent::new(sacp::schema::McpServer::Stdio(server)).with_debug( move |line, dir| { - if dir == sacp_tokio::LineDirection::Stderr { - eprintln!("[ACP][{agent_name}][stderr] {line}"); + let (tag, enabled) = match dir { + sacp_tokio::LineDirection::Stderr => ("stderr", true), + sacp_tokio::LineDirection::Stdout => ("stdout", stdio_debug_enabled), + sacp_tokio::LineDirection::Stdin => ("stdin", stdio_debug_enabled), + }; + if !enabled { + return; + } + const MAX: usize = 256; + if line.len() > MAX { + let head = line + .char_indices() + .take_while(|(i, _)| *i < MAX) + .last() + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(MAX); + eprintln!( + "[ACP][{agent_name}][{tag}] {}... ", + &line[..head], + line.len() - head + ); + } else { + eprintln!("[ACP][{agent_name}][{tag}] {line}"); } }, )) @@ -246,6 +347,13 @@ async fn build_agent( } /// Spawn an ACP agent process and run the connection loop in a background task. +/// +/// On success, the newly created `AgentConnection` is inserted into +/// `connections` before this function returns. The background task +/// automatically removes the entry from `connections` once `run_connection` +/// exits (timeout, error, or clean disconnect), so the manager never +/// leaks stale entries after a connection tears down. +#[allow(clippy::too_many_arguments)] pub async fn spawn_agent_connection( connection_id: String, agent_type: AgentType, @@ -254,7 +362,8 @@ pub async fn spawn_agent_connection( runtime_env: BTreeMap, owner_window_label: String, emitter: EventEmitter, -) -> Result { + connections: Arc>>, +) -> Result<(), AcpError> { crate::web::event_bridge::emit_event( &emitter, "acp://event", @@ -264,13 +373,36 @@ pub async fn spawn_agent_connection( }, ); - let agent = build_agent(agent_type, &runtime_env, &connection_id, &emitter).await?; + let agent = build_agent(agent_type, &runtime_env).await?; let (cmd_tx, cmd_rx) = mpsc::channel::(32); let conn_id = connection_id.clone(); let emitter_clone = emitter.clone(); + let cleanup_connections = connections.clone(); + let cleanup_connection_id = connection_id.clone(); + + // Insert the entry BEFORE spawning the background task so that a + // fast-failing `run_connection` can never remove it before it was + // inserted (would otherwise leak the entry). + connections.lock().await.insert( + connection_id.clone(), + AgentConnection { + id: connection_id, + agent_type, + status: ConnectionStatus::Connecting, + owner_window_label, + cmd_tx, + }, + ); tokio::spawn(async move { + // RAII guard: runs on normal exit AND on panic unwinding, so a + // panic inside `run_connection` can't leak a stale map entry. + let _cleanup = ConnectionCleanupGuard { + connections: cleanup_connections, + connection_id: cleanup_connection_id, + }; + let result = run_connection( agent, conn_id.clone(), @@ -283,6 +415,7 @@ pub async fn spawn_agent_connection( .await; if let Err(e) = result { + let code = e.code().map(String::from); crate::web::event_bridge::emit_event( &emitter_clone, "acp://event", @@ -290,6 +423,7 @@ pub async fn spawn_agent_connection( connection_id: conn_id.clone(), message: e.to_string(), agent_type: agent_type.to_string(), + code, }, ); } @@ -302,15 +436,11 @@ pub async fn spawn_agent_connection( status: ConnectionStatus::Disconnected, }, ); + // `_cleanup` is dropped here — removes the connection entry from + // the manager map. Same drop semantics apply on panic unwinding. }); - Ok(AgentConnection { - id: connection_id, - agent_type, - status: ConnectionStatus::Connecting, - owner_window_label, - cmd_tx, - }) + Ok(()) } /// Shared state for pending permission responders. @@ -699,6 +829,8 @@ async fn run_connection( on_receive_request!(), ) .connect_with(agent, async move |cx| -> Result<(), sacp::Error> { + let agent_name_for_log = registry::get_agent_meta(agent_type).name; + // Advertise filesystem + terminal capabilities for ACP tool execution. let init_request = InitializeRequest::new(ProtocolVersion::LATEST).client_capabilities( ClientCapabilities::new() @@ -707,7 +839,52 @@ async fn run_connection( .read_text_file(true) .write_text_file(true)), ); - let init_resp = cx.send_request_to(Agent, init_request).block_task().await?; + // Bound the Initialize handshake so an outdated / incompatible + // cached binary that never responds can't leave the frontend + // stuck on "Connecting...". A healthy agent answers in <1s; we + // give 60s headroom for cold process startup on slow machines. + // + // We cannot carry a structured error code through sacp's Error + // type, so we tag the timeout with `INIT_TIMEOUT_SENTINEL` and + // convert it back to `AcpError::InitializeTimeout` in the + // outer `.map_err(...)` below. The outer layer attaches a + // stable `code` to the frontend event so it can be localized. + eprintln!( + "[ACP][{agent_name_for_log}] Sending Initialize (protocol={}, timeout=60s)", + ProtocolVersion::LATEST + ); + let init_started = std::time::Instant::now(); + let init_resp = match tokio::time::timeout( + std::time::Duration::from_secs(60), + cx.send_request_to(Agent, init_request).block_task(), + ) + .await + { + Ok(Ok(resp)) => { + eprintln!( + "[ACP][{agent_name_for_log}] Initialize responded in {:?}", + init_started.elapsed() + ); + resp + } + Ok(Err(e)) => { + eprintln!( + "[ACP][{agent_name_for_log}] Initialize failed in {:?}: {e}", + init_started.elapsed() + ); + return Err(e); + } + Err(_) => { + eprintln!( + "[ACP][{agent_name_for_log}] Initialize TIMED OUT after {:?} \ + — the agent never answered the handshake. Check the \ + [stderr] lines above for agent-side errors. For a full \ + JSON-RPC trace, re-launch with CODEG_ACP_DEBUG=1.", + init_started.elapsed() + ); + return Err(sacp::util::internal_error(INIT_TIMEOUT_SENTINEL)); + } + }; emit_prompt_capabilities( &conn_id, &emitter_clone, @@ -860,6 +1037,7 @@ async fn run_connection( "Failed to load session, starting new: {e}" ), agent_type: agent_type.to_string(), + code: None, }, ); } @@ -968,7 +1146,14 @@ async fn run_connection( } }) .await - .map_err(|e| AcpError::protocol(e.to_string())) + .map_err(|e| { + let raw = e.to_string(); + if raw.contains(INIT_TIMEOUT_SENTINEL) { + AcpError::InitializeTimeout + } else { + AcpError::protocol(raw) + } + }) } /// Store the permission responder and emit event to frontend. @@ -1614,6 +1799,7 @@ async fn run_conversation_loop<'a>( connection_id: conn_id.into(), message: "Prompt must contain at least one content block".into(), agent_type: agent_type.to_string(), + code: None, }, ); continue; @@ -1802,6 +1988,7 @@ async fn run_conversation_loop<'a>( connection_id: conn_id.into(), message: format!("Failed to set mode: {e}"), agent_type: agent_type.to_string(), + code: None, }, ); } @@ -1829,6 +2016,7 @@ async fn run_conversation_loop<'a>( connection_id: conn_id.into(), message: format!("Failed to set config option: {e}"), agent_type: agent_type.to_string(), + code: None, }, ); } @@ -1938,6 +2126,7 @@ async fn run_conversation_loop<'a>( connection_id: conn_id.into(), message: format!("Failed to set mode: {e}"), agent_type: agent_type.to_string(), + code: None, }, ); } @@ -1958,6 +2147,7 @@ async fn run_conversation_loop<'a>( connection_id: conn_id.into(), message: format!("Failed to set config option: {e}"), agent_type: agent_type.to_string(), + code: None, }, ); } diff --git a/src-tauri/src/acp/error.rs b/src-tauri/src/acp/error.rs index ac048bc..ba04faf 100644 --- a/src-tauri/src/acp/error.rs +++ b/src-tauri/src/acp/error.rs @@ -14,6 +14,10 @@ pub enum AcpError { DownloadFailed(String), #[error("platform not supported: {0}")] PlatformNotSupported(String), + #[error("{0}")] + SdkNotInstalled(String), + #[error("Agent did not respond to Initialize within 60 seconds. The cached binary may be outdated or incompatible. Try upgrading it from Agent Settings.")] + InitializeTimeout, } impl AcpError { @@ -30,6 +34,25 @@ impl AcpError { Self::Protocol(sanitized) } + + /// Stable machine-readable identifier for this error kind. + /// + /// Returned to the frontend alongside the human-readable message so + /// the UI can render a localized message based on the code instead + /// of parsing English text. `None` means "no stable code — show the + /// raw message as a fallback". + pub fn code(&self) -> Option<&'static str> { + match self { + Self::SdkNotInstalled(_) => Some("sdk_not_installed"), + Self::PlatformNotSupported(_) => Some("platform_not_supported"), + Self::InitializeTimeout => Some("initialize_timeout"), + Self::ProcessExited => Some("process_exited"), + Self::SpawnFailed(_) => Some("spawn_failed"), + Self::DownloadFailed(_) => Some("download_failed"), + Self::ConnectionNotFound(_) => Some("connection_not_found"), + Self::Protocol(_) => None, + } + } } impl Serialize for AcpError { diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index ec45df5..7a9b838 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -42,7 +42,11 @@ impl ConnectionManager { connection_id, owner_window_label, agent_type ); - let conn = spawn_agent_connection( + // `spawn_agent_connection` inserts the entry into `self.connections` + // itself and registers a cleanup hook that removes it once the + // background `run_connection` task exits. This keeps the manager + // from leaking entries after timeouts / errors. + spawn_agent_connection( connection_id.clone(), agent_type, working_dir, @@ -50,14 +54,10 @@ impl ConnectionManager { runtime_env, owner_window_label, emitter, + self.connections.clone(), ) .await?; - self.connections - .lock() - .await - .insert(connection_id.clone(), conn); - Ok(connection_id) } diff --git a/src-tauri/src/acp/preflight.rs b/src-tauri/src/acp/preflight.rs index 4890978..1a1bb3c 100644 --- a/src-tauri/src/acp/preflight.rs +++ b/src-tauri/src/acp/preflight.rs @@ -299,32 +299,51 @@ async fn check_binary_environment( }; checks.push(platform_check); - // Check binary cache + // Check binary cache. + // + // Pass as long as *any* cached version is present — the session-page + // connect path uses the best cached version via + // `find_best_cached_binary_for_agent`, so an older-but-working cache + // should still be considered "ready". If the cached version differs + // from the registry's recommended version, we note it in the message + // but still pass — the Settings page's version-badge flow is the + // canonical place to surface "upgrade available". if platform_supported { - let cache_check = match binary_cache::find_cached_binary_for_agent(agent_type, version, cmd) - { - Ok(Some(_)) => CheckItem { - check_id: "binary_cached".into(), - label: "Binary cache".into(), - status: CheckStatus::Pass, - message: "Binary is cached locally".into(), - fixes: vec![], - }, - Ok(None) => CheckItem { - check_id: "binary_cached".into(), - label: "Binary cache".into(), - status: CheckStatus::Warn, - message: "Binary not cached yet, will be downloaded on first connection".into(), - fixes: vec![], - }, - Err(_) => CheckItem { - check_id: "binary_cached".into(), - label: "Binary cache".into(), - status: CheckStatus::Warn, - message: "Cannot determine binary cache path".into(), - fixes: vec![], - }, - }; + let cache_check = + match binary_cache::find_best_cached_binary_for_agent(agent_type, cmd) { + Ok(Some((_, cached_version))) => { + let message = if cached_version == version { + "Binary is cached locally".to_string() + } else { + format!( + "Binary {cached_version} is cached locally (recommended: {version})" + ) + }; + CheckItem { + check_id: "binary_cached".into(), + label: "Binary cache".into(), + status: CheckStatus::Pass, + message, + fixes: vec![], + } + } + Ok(None) => CheckItem { + check_id: "binary_cached".into(), + label: "Binary cache".into(), + status: CheckStatus::Warn, + message: + "Binary is not installed. Download it from Agent Settings before connecting." + .into(), + fixes: vec![], + }, + Err(_) => CheckItem { + check_id: "binary_cached".into(), + label: "Binary cache".into(), + status: CheckStatus::Warn, + message: "Cannot determine binary cache path".into(), + fixes: vec![], + }, + }; checks.push(cache_check); } diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index d634428..b31e985 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -131,6 +131,10 @@ pub enum AcpEvent { connection_id: String, message: String, agent_type: String, + /// Stable machine-readable identifier (e.g. "initialize_timeout"). + /// When present, the frontend renders a localized message keyed on + /// this code; otherwise it falls back to `message`. + code: Option, }, /// Available slash commands updated AvailableCommands { @@ -212,7 +216,6 @@ pub struct PlanEntryInfo { #[serde(rename_all = "snake_case")] pub enum ConnectionStatus { Connecting, - Downloading, Connected, Prompting, Disconnected, diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 4f019ab..a6bf4f3 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -92,6 +92,58 @@ pub(crate) fn is_cmd_available(cmd: &str) -> bool { which::which(cmd).is_ok() } +/// Verify that the agent SDK / binary is installed and usable. +/// +/// This is the pre-spawn guard used by the session-page connect path: +/// the session page must NEVER trigger a download or install, so if the +/// agent isn't ready we return `AcpError::SdkNotInstalled` immediately +/// and let the frontend prompt the user to install from Agent Settings. +/// +/// For NPX agents: checks the command exists on PATH. +/// For Binary agents: checks platform support and that the binary is +/// already cached locally. +pub(crate) fn verify_agent_installed(agent_type: AgentType) -> Result<(), AcpError> { + let meta = registry::get_agent_meta(agent_type); + match meta.distribution { + registry::AgentDistribution::Npx { cmd, .. } => { + if !is_cmd_available(cmd) { + // INVARIANT: the substring "is not installed" is matched + // verbatim by the frontend catch block in + // `src/contexts/acp-connections-context.tsx` to surface a + // localized install prompt. Do not change the wording. + return Err(AcpError::SdkNotInstalled(format!( + "{} is not installed. Please install it in Agent Settings.", + meta.name + ))); + } + Ok(()) + } + registry::AgentDistribution::Binary { + cmd, platforms, .. + } => { + let platform = registry::current_platform(); + if !platforms.iter().any(|p| p.platform == platform) { + return Err(AcpError::PlatformNotSupported(format!( + "{} is not available on {platform}", + meta.name + ))); + } + // Accept any cached version — the Settings page will still + // surface "upgrade available" for stale caches via its own + // version-badge flow. + if binary_cache::find_best_cached_binary_for_agent(agent_type, cmd)?.is_none() { + // INVARIANT: see note above — "is not installed" is a + // stable substring the frontend matches against. + return Err(AcpError::SdkNotInstalled(format!( + "{} is not installed. Please install it in Agent Settings.", + meta.name + ))); + } + Ok(()) + } + } +} + /// Detect the actual installed version of an npm global package by running /// `npm list -g --json` and parsing the JSON output. /// @@ -1798,8 +1850,6 @@ pub async fn acp_connect( app_handle: tauri::AppHandle, window: tauri::WebviewWindow, ) -> Result { - let meta = registry::get_agent_meta(agent_type); - let setting = agent_setting_service::get_by_agent_type(&db.conn, agent_type) .await .map_err(|e| AcpError::protocol(e.to_string()))?; @@ -1825,14 +1875,10 @@ pub async fn acp_connect( runtime_env.insert("OPENCLAW_RESET_SESSION".into(), "1".into()); } - if let registry::AgentDistribution::Npx { cmd, .. } = meta.distribution { - if !is_cmd_available(cmd) { - return Err(AcpError::protocol(format!( - "{} SDK is not installed. Please install it in Agent Settings.", - meta.name - ))); - } - } + // Guard: the session page must never trigger a download or install. + // If the agent isn't ready, return SdkNotInstalled here so the frontend + // can prompt the user to install it from Agent Settings. + verify_agent_installed(agent_type)?; let emitter = EventEmitter::Tauri(app_handle); manager diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 1084020..a85f597 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -5,7 +5,6 @@ use axum::{extract::Extension, Json}; use serde::Deserialize; use crate::acp::preflight::PreflightResult; -use crate::acp::registry; use crate::acp::types::{ AcpAgentInfo, AcpAgentStatus, AgentSkillContent, AgentSkillLayout, AgentSkillScope, AgentSkillsListResult, ConnectionInfo, ForkResultInfo, @@ -57,7 +56,6 @@ pub async fn acp_connect( ) -> Result, AppCommandError> { let db = &state.db; let manager = &state.connection_manager; - let meta = registry::get_agent_meta(params.agent_type); let setting = agent_setting_service::get_by_agent_type(&db.conn, params.agent_type) .await @@ -93,14 +91,11 @@ pub async fn acp_connect( runtime_env.insert("OPENCLAW_RESET_SESSION".into(), "1".into()); } - if let registry::AgentDistribution::Npx { cmd, .. } = meta.distribution { - if !acp_commands::is_cmd_available(cmd) { - return Err(AppCommandError::task_execution_failed(format!( - "{} SDK is not installed. Please install it in Agent Settings.", - meta.name - ))); - } - } + // Guard: the session page must never trigger a download or install. + // If the agent isn't ready, return SdkNotInstalled here so the frontend + // can prompt the user to install it from Agent Settings. + acp_commands::verify_agent_installed(params.agent_type) + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; let emitter = state.emitter.clone(); let connection_id = manager diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index cfc1f97..9fac5bb 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -84,7 +84,7 @@ export function ChatInput({ const t = useTranslations("Folder.chat.chatInput") const isConnected = status === "connected" const isPrompting = status === "prompting" - const isConnecting = status === "connecting" || status === "downloading" + const isConnecting = status === "connecting" return (
diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 4e5bcb5..47635ab 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -125,7 +125,7 @@ function normalizeErrorMessage(error: unknown): string { return String(error) } -function isExpectedAutoLinkError(error: unknown): boolean { +function isExpectedConnectError(error: unknown): boolean { if (!error || typeof error !== "object") return false return (error as { alerted?: unknown }).alerted === true } @@ -310,8 +310,7 @@ const ConversationTabView = memo(function ConversationTabView({ useEffect(() => { connStatusRef.current = connStatus }, [connStatus]) - const isConnecting = - connStatus === "connecting" || connStatus === "downloading" + const isConnecting = connStatus === "connecting" const connectionModes = useMemo( () => conn.modes?.available_modes ?? [], [conn.modes?.available_modes] @@ -756,15 +755,13 @@ const ConversationTabView = memo(function ConversationTabView({ const s = connStatusRef.current const doConnect = () => { if (!workingDirForConnection) return - connConnect(nextAgentType, workingDirForConnection, undefined, { - source: "auto_link", - }) + connConnect(nextAgentType, workingDirForConnection, undefined) .then(() => { setAgentConnectError(null) }) .catch((e) => { setAgentConnectError(normalizeErrorMessage(e)) - if (!isExpectedAutoLinkError(e)) { + if (!isExpectedConnectError(e)) { console.error("[ConversationTabView] switch agent:", e) } }) diff --git a/src/components/layout/status-bar-connection.tsx b/src/components/layout/status-bar-connection.tsx index 5db0e22..eeb0108 100644 --- a/src/components/layout/status-bar-connection.tsx +++ b/src/components/layout/status-bar-connection.tsx @@ -19,7 +19,6 @@ import { cn } from "@/lib/utils" type ConnectionStatusLabelKey = | "connected" | "connecting" - | "downloading" | "prompting" | "error" @@ -32,10 +31,6 @@ const STATUS_STYLE: Record< className: "opacity-100 animate-pulse", labelKey: "connecting", }, - downloading: { - className: "opacity-100 animate-pulse", - labelKey: "downloading", - }, prompting: { className: "opacity-100 animate-pulse", labelKey: "prompting", diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index 448f0c2..a10b00c 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -1106,19 +1106,12 @@ export function useConnectionStore(): ConnectionStoreApi { // ── Actions context (unchanged interface) ── -export type ConnectSource = "manual" | "auto_link" - -export interface ConnectOptions { - source?: ConnectSource -} - export interface AcpActionsValue { connect( contextKey: string, agentType: AgentType, workingDir?: string, - sessionId?: string, - options?: ConnectOptions + sessionId?: string ): Promise disconnect(contextKey: string): Promise disconnectAll(): Promise @@ -1211,7 +1204,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { // Keys whose disconnect was requested while connect was still in flight const abandonedKeysRef = useRef(new Set()) - type AutoLinkBlockState = + type ConnectBlockState = | { kind: "none"; reason: "" } | { kind: "missing_config" | "disabled" | "unavailable" | "sdk_missing" @@ -1236,8 +1229,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { [t] ) - const resolveAutoLinkBlockState = useCallback( - (agent: AcpAgentStatus | null): AutoLinkBlockState => { + const resolveConnectBlockState = useCallback( + (agent: AcpAgentStatus | null): ConnectBlockState => { if (!agent) { return { kind: "missing_config", reason: t("blocked.missingConfig") } } @@ -1714,23 +1707,54 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { } case "error": { flushStreamingQueue() - dispatch({ type: "ERROR", contextKey, message: e.message }) - pushAlertRef.current("error", t("eventErrorTitle"), e.message) - // Send OS notification for agent errors - { - const nc = storeRef.current.connections.get(contextKey) - if (nc) { - const agentLabel = AGENT_LABELS[nc.agentType] - const fn = folderNameRef.current - const title = fn ? `${fn} - Codeg` : "Codeg" - sendSystemNotification( - title, - t("notificationError", { + const nc = storeRef.current.connections.get(contextKey) + const agentLabel = nc + ? AGENT_LABELS[nc.agentType] + : (e.agent_type as string) + + // Localize backend errors via their stable `code` identifier. + // Unknown codes fall back to the raw English message so we + // never swallow a useful stack trace. + const localizedMessage = (() => { + switch (e.code) { + case "initialize_timeout": + return t("backendErrors.initializeTimeout", { + agent: agentLabel, + }) + case "sdk_not_installed": + return t("blocked.sdkMissing", { agent: agentLabel }) + case "platform_not_supported": + return t("blocked.unavailable", { agent: agentLabel }) + case "process_exited": + return t("backendErrors.processExited", { agent: agentLabel }) + case "spawn_failed": + return t("backendErrors.spawnFailed", { agent: agentLabel, message: e.message, }) - ).catch(() => {}) + case "download_failed": + return t("backendErrors.downloadFailed", { + agent: agentLabel, + message: e.message, + }) + default: + return e.message } + })() + + dispatch({ type: "ERROR", contextKey, message: localizedMessage }) + pushAlertRef.current("error", t("eventErrorTitle"), localizedMessage) + // Send OS notification for agent errors + if (nc) { + const fn = folderNameRef.current + const title = fn ? `${fn} - Codeg` : "Codeg" + sendSystemNotification( + title, + t("notificationError", { + agent: agentLabel, + message: localizedMessage, + }) + ).catch(() => {}) } break } @@ -1821,11 +1845,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { for (const [contextKey, conn] of storeRef.current.connections) { if (contextKey === currentActiveKey) continue if (currentOpenTabKeys.has(contextKey)) continue - if ( - conn.status === "prompting" || - conn.status === "connecting" || - conn.status === "downloading" - ) { + if (conn.status === "prompting" || conn.status === "connecting") { continue } if (conn.status !== "connected") continue @@ -1865,60 +1885,54 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { contextKey: string, agentType: AgentType, workingDir?: string, - sessionId?: string, - options?: ConnectOptions + sessionId?: string ) => { - const source = options?.source ?? "manual" - const isAutoLink = source === "auto_link" - if (connectingKeysRef.current.has(contextKey)) return connectingKeysRef.current.add(contextKey) try { - if (isAutoLink) { - let configuredAgent: AcpAgentStatus | null = null - try { - configuredAgent = await acpGetAgentStatus(agentType) - } catch (error) { - const reason = t("unableReadAgentConfig", { - message: normalizeErrorMessage(error), - }) - const autoLinkFailedTitle = t("autoLinkFailedTitle", { - agent: AGENT_LABELS[agentType], - }) - pushAlertRef.current( - "error", - autoLinkFailedTitle, - `${reason}\n${t("agentsSetupHint")}`, - [buildOpenAgentsSettingsAction(agentType)] - ) - throw createAlertedError(reason) - } + // Preflight: read agent status and block if the SDK / binary is + // not installed. The session page must never trigger a download + // or install — if the agent is not ready, prompt the user to + // install it from Agent Settings instead. + let configuredAgent: AcpAgentStatus | null = null + try { + configuredAgent = await acpGetAgentStatus(agentType) + } catch (error) { + const reason = t("unableReadAgentConfig", { + message: normalizeErrorMessage(error), + }) + const failedTitle = t("connectFailedTitle", { + agent: AGENT_LABELS[agentType], + }) + pushAlertRef.current( + "error", + failedTitle, + `${reason}\n${t("agentsSetupHint")}`, + [buildOpenAgentsSettingsAction(agentType)] + ) + throw createAlertedError(reason) + } - const blocked = resolveAutoLinkBlockState(configuredAgent) - if (blocked.kind !== "none") { - const autoLinkFailedTitle = t("autoLinkFailedTitle", { - agent: AGENT_LABELS[agentType], - }) - const detail = - blocked.kind === "sdk_missing" - ? t("withSetupHint", { - message: blocked.reason, - hint: t("agentsSetupHint"), - }) - : `${blocked.reason}\n${t("agentsSetupHint")}` - pushAlertRef.current( - "error", - blocked.kind === "sdk_missing" - ? blocked.reason - : autoLinkFailedTitle, - detail, - [buildOpenAgentsSettingsAction(agentType)] - ) - throw createAlertedError( - blocked.kind === "sdk_missing" ? blocked.reason : blocked.reason - ) - } + const blocked = resolveConnectBlockState(configuredAgent) + if (blocked.kind !== "none") { + const failedTitle = t("connectFailedTitle", { + agent: AGENT_LABELS[agentType], + }) + const detail = + blocked.kind === "sdk_missing" + ? t("withSetupHint", { + message: blocked.reason, + hint: t("agentsSetupHint"), + }) + : `${blocked.reason}\n${t("agentsSetupHint")}` + pushAlertRef.current( + "error", + blocked.kind === "sdk_missing" ? blocked.reason : failedTitle, + detail, + [buildOpenAgentsSettingsAction(agentType)] + ) + throw createAlertedError(blocked.reason) } const existing = storeRef.current.connections.get(contextKey) @@ -1970,11 +1984,37 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { } catch (err) { if (!isAlertedError(err)) { const message = normalizeErrorMessage(err) - pushAlertRef.current( - "error", - t("connectFailedTitle", { agent: agentType }), - message - ) + const agentLabel = AGENT_LABELS[agentType] + // Backend safety net: if the agent turned out to be not + // installed (e.g. the binary was removed between preflight + // and spawn), surface the same install prompt with a direct + // "Open Agent Settings" action. Title is localized via the + // same i18n key the preflight path uses. + // + // INVARIANT: `AcpError::SdkNotInstalled` renders its payload + // unchanged, and both producers + // (`src-tauri/src/commands/acp.rs::verify_agent_installed` + // and `src-tauri/src/acp/connection.rs::build_agent` Binary + // branch) format the message with the literal English + // substring "is not installed". Do NOT translate those two + // format strings — this branch matches on them as a stable + // identifier, since `AcpError::Serialize` flattens to a bare + // message string and does not expose the error `code` for + // synchronous Tauri command rejections. + if (message.includes("is not installed")) { + pushAlertRef.current( + "error", + t("blocked.sdkMissing", { agent: agentLabel }), + t("agentsSetupHint"), + [buildOpenAgentsSettingsAction(agentType)] + ) + } else { + pushAlertRef.current( + "error", + t("connectFailedTitle", { agent: agentLabel }), + message + ) + } } throw err } finally { @@ -1987,7 +2027,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { consumeBufferedEvents, dispatch, handleMappedEvent, - resolveAutoLinkBlockState, + resolveConnectBlockState, t, waitForListenerReady, ] diff --git a/src/hooks/use-connection-lifecycle.ts b/src/hooks/use-connection-lifecycle.ts index a0742f9..7f7d4ad 100644 --- a/src/hooks/use-connection-lifecycle.ts +++ b/src/hooks/use-connection-lifecycle.ts @@ -33,7 +33,7 @@ function normalizeErrorMessage(error: unknown): string { return String(error) } -function isExpectedAutoLinkError(error: unknown): boolean { +function isExpectedConnectError(error: unknown): boolean { if (!error || typeof error !== "object") return false return (error as { alerted?: unknown }).alerted === true } @@ -77,12 +77,10 @@ export function useConnectionLifecycle({ const modeLoading = !hasCachedSelectors && (status === "connecting" || - status === "downloading" || (isInteractiveStatus && !effectiveSelectorsReady)) const configOptionsLoading = !hasCachedSelectors && (status === "connecting" || - status === "downloading" || (isInteractiveStatus && !effectiveSelectorsReady)) // Gate for send button: block until the backend session is fully // initialized (selectorsReady from the real backend event, not cache). @@ -141,9 +139,7 @@ export function useConnectionLifecycle({ const s = statusRef.current if (!s || s === "disconnected" || s === "error") { connConnectRef - .current(agentTypeRef.current, workingDir, sessionIdRef.current, { - source: "auto_link", - }) + .current(agentTypeRef.current, workingDir, sessionIdRef.current) .then(() => { if (!cancelled) { setLastAutoConnectError(null) @@ -157,7 +153,7 @@ export function useConnectionLifecycle({ message: normalizeErrorMessage(e), }) } - if (!isExpectedAutoLinkError(e)) { + if (!isExpectedConnectError(e)) { console.error("[ConnLifecycle] auto-connect:", e) } }) @@ -170,7 +166,7 @@ export function useConnectionLifecycle({ // Manage task status for connection progress const taskIdRef = useRef(null) useEffect(() => { - if (status === "connecting" || status === "downloading") { + if (status === "connecting") { if (!taskIdRef.current) { const id = `acp-connect-${Date.now()}` taskIdRef.current = id @@ -271,10 +267,8 @@ export function useConnectionLifecycle({ touchActivity(contextKey) if (!status || status === "disconnected" || status === "error") { setLastAutoConnectError(null) - connConnect(agentType, workingDir, sessionId, { - source: "auto_link", - }).catch((e: unknown) => { - if (!isExpectedAutoLinkError(e)) { + connConnect(agentType, workingDir, sessionId).catch((e: unknown) => { + if (!isExpectedConnectError(e)) { console.error("[ConnLifecycle] connect:", e) } }) diff --git a/src/hooks/use-connection.ts b/src/hooks/use-connection.ts index 039c214..c73f295 100644 --- a/src/hooks/use-connection.ts +++ b/src/hooks/use-connection.ts @@ -6,7 +6,6 @@ import { useConnectionStore, getCachedSelectors, type ConnectionState, - type ConnectOptions, type LiveMessage, type PendingPermission, type PendingQuestion, @@ -45,8 +44,7 @@ export interface UseConnectionReturn { connect: ( agentType: AgentType, workingDir?: string, - sessionId?: string, - options?: ConnectOptions + sessionId?: string ) => Promise disconnect: () => Promise sendPrompt: (blocks: PromptInputBlock[]) => Promise @@ -96,12 +94,8 @@ export function useConnection(contextKey: string): UseConnectionReturn { const error = connection?.error ?? null const connect = useCallback( - ( - agentType: AgentType, - workingDir?: string, - sessionId?: string, - options?: ConnectOptions - ) => actions.connect(contextKey, agentType, workingDir, sessionId, options), + (agentType: AgentType, workingDir?: string, sessionId?: string) => + actions.connect(contextKey, agentType, workingDir, sessionId), [actions, contextKey] ) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 70b2a38..d6c9674 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -851,7 +851,6 @@ "connection": { "connected": "متصل", "connecting": "جارٍ الاتصال...", - "downloading": "جارٍ التنزيل...", "prompting": "جارٍ الرد...", "error": "خطأ في الاتصال", "disconnected": "غير متصل", @@ -1422,11 +1421,13 @@ "unavailable": "{agent} غير متاح على المنصة الحالية.", "sdkMissing": "لم يتم تثبيت SDK الخاص بـ {agent}" }, + "backendErrors": { + "initializeTimeout": "انتهت مهلة مصافحة اتصال {agent} (لا استجابة بعد 60 ثانية). افتح الإعدادات للتحقق من إعدادات الوكيل والشبكة.", + "processExited": "انتهت عملية {agent} بشكل غير متوقع.", + "spawnFailed": "تعذر بدء تشغيل {agent}: {message}", + "downloadFailed": "فشل تنزيل {agent}: {message}" + }, "unableReadAgentConfig": "تعذر قراءة إعدادات الوكيل: {message}", - "autoLinkFailedTitle": "فشل الربط التلقائي لـ {agent}", - "preflightCheckFailedDefault": "فشلت فحوصات ما قبل التشغيل. تحقق من إعدادات الوكلاء.", - "preflightFailedTitle": "فشل فحص ما قبل التشغيل لـ {agent}", - "autoLinkPreflightFailed": "فشل فحص ما قبل التشغيل للربط التلقائي: {message}", "connectFailedTitle": "فشل اتصال {agent}", "toolFallbackTitle": "أداة", "eventErrorTitle": "خطأ الوكيل", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 813180b..b624c70 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -851,7 +851,6 @@ "connection": { "connected": "Verbunden", "connecting": "Verbinde...", - "downloading": "Lade herunter...", "prompting": "Antworte...", "error": "Verbindungsfehler", "disconnected": "Getrennt", @@ -1422,11 +1421,13 @@ "unavailable": "{agent} ist auf der aktuellen Plattform nicht verfügbar.", "sdkMissing": "{agent} SDK ist nicht installiert" }, + "backendErrors": { + "initializeTimeout": "Der Verbindungs-Handshake von {agent} hat nach 60 Sekunden das Zeitlimit überschritten. Öffnen Sie die Einstellungen, um Agenten- und Netzwerkkonfiguration zu prüfen.", + "processExited": "{agent}-Prozess wurde unerwartet beendet.", + "spawnFailed": "{agent} konnte nicht gestartet werden: {message}", + "downloadFailed": "{agent}-Download fehlgeschlagen: {message}" + }, "unableReadAgentConfig": "Agenten-Konfiguration kann nicht gelesen werden: {message}", - "autoLinkFailedTitle": "{agent} Auto-Link fehlgeschlagen", - "preflightCheckFailedDefault": "Preflight-Prüfungen fehlgeschlagen. Prüfen Sie die Agenten-Einstellungen.", - "preflightFailedTitle": "{agent} Preflight fehlgeschlagen", - "autoLinkPreflightFailed": "Auto-Link-Preflight fehlgeschlagen: {message}", "connectFailedTitle": "{agent} Verbindung fehlgeschlagen", "toolFallbackTitle": "Werkzeug", "eventErrorTitle": "Agentenfehler", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index a62984d..7a0aed9 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -851,7 +851,6 @@ "connection": { "connected": "Connected", "connecting": "Connecting...", - "downloading": "Downloading...", "prompting": "Responding...", "error": "Connection error", "disconnected": "Disconnected", @@ -1422,11 +1421,13 @@ "unavailable": "{agent} is unavailable on the current platform.", "sdkMissing": "{agent} SDK is not installed" }, + "backendErrors": { + "initializeTimeout": "{agent} connection handshake timed out after 60 seconds. Please open Settings to check Agent configuration and network settings.", + "processExited": "{agent} process exited unexpectedly.", + "spawnFailed": "Failed to start {agent}: {message}", + "downloadFailed": "{agent} download failed: {message}" + }, "unableReadAgentConfig": "Unable to read Agent config: {message}", - "autoLinkFailedTitle": "{agent} auto-link failed", - "preflightCheckFailedDefault": "Preflight checks failed. Check Agent settings.", - "preflightFailedTitle": "{agent} preflight failed", - "autoLinkPreflightFailed": "Auto-link preflight failed: {message}", "connectFailedTitle": "{agent} connection failed", "toolFallbackTitle": "Tool", "eventErrorTitle": "Agent Error", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index fc0f0bc..8c2f68a 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -851,7 +851,6 @@ "connection": { "connected": "Conectado", "connecting": "Conectando...", - "downloading": "Descargando...", "prompting": "Respondiendo...", "error": "Error de conexión", "disconnected": "Desconectado", @@ -1422,11 +1421,13 @@ "unavailable": "{agent} no está disponible en la plataforma actual.", "sdkMissing": "El SDK de {agent} no está instalado" }, + "backendErrors": { + "initializeTimeout": "Se agotó el tiempo del handshake de conexión de {agent} (sin respuesta tras 60 segundos). Abre Ajustes para revisar la configuración del agente y de la red.", + "processExited": "El proceso de {agent} terminó inesperadamente.", + "spawnFailed": "No se pudo iniciar {agent}: {message}", + "downloadFailed": "La descarga de {agent} falló: {message}" + }, "unableReadAgentConfig": "No se puede leer la configuración del agente: {message}", - "autoLinkFailedTitle": "Falló el autovínculo de {agent}", - "preflightCheckFailedDefault": "Fallaron las comprobaciones previas. Revisa los ajustes de agentes.", - "preflightFailedTitle": "Falló la verificación previa de {agent}", - "autoLinkPreflightFailed": "Falló la verificación previa del autovínculo: {message}", "connectFailedTitle": "Falló la conexión de {agent}", "toolFallbackTitle": "Herramienta", "eventErrorTitle": "Error del agente", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 67c0fa0..10cba72 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -851,7 +851,6 @@ "connection": { "connected": "Connecté", "connecting": "Connexion...", - "downloading": "Téléchargement...", "prompting": "Réponse...", "error": "Erreur de connexion", "disconnected": "Déconnecté", @@ -1422,11 +1421,13 @@ "unavailable": "{agent} n'est pas disponible sur la plateforme actuelle.", "sdkMissing": "Le SDK de {agent} n'est pas installé" }, + "backendErrors": { + "initializeTimeout": "Le handshake de connexion de {agent} a expiré (aucune réponse après 60 secondes). Ouvrez Paramètres pour vérifier la configuration de l'agent et du réseau.", + "processExited": "Le processus {agent} s'est arrêté de manière inattendue.", + "spawnFailed": "Impossible de démarrer {agent} : {message}", + "downloadFailed": "Échec du téléchargement de {agent} : {message}" + }, "unableReadAgentConfig": "Impossible de lire la configuration de l'agent : {message}", - "autoLinkFailedTitle": "Échec de l'auto-liaison de {agent}", - "preflightCheckFailedDefault": "Les vérifications préalables ont échoué. Vérifiez les paramètres des agents.", - "preflightFailedTitle": "Échec de la vérification préalable de {agent}", - "autoLinkPreflightFailed": "Échec de la vérification préalable de l'auto-liaison : {message}", "connectFailedTitle": "Échec de la connexion de {agent}", "toolFallbackTitle": "Outil", "eventErrorTitle": "Erreur de l'agent", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 94c095d..c92bec0 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -851,7 +851,6 @@ "connection": { "connected": "接続済み", "connecting": "接続中...", - "downloading": "ダウンロード中...", "prompting": "応答中...", "error": "接続エラー", "disconnected": "未接続", @@ -1422,11 +1421,13 @@ "unavailable": "{agent} は現在のプラットフォームでは利用できません。", "sdkMissing": "{agent} SDK がインストールされていません" }, + "backendErrors": { + "initializeTimeout": "{agent} の接続ハンドシェイクがタイムアウトしました(60 秒以内に応答なし)。設定を開いてエージェントとネットワーク設定を確認してください。", + "processExited": "{agent} プロセスが予期せず終了しました。", + "spawnFailed": "{agent} の起動に失敗しました: {message}", + "downloadFailed": "{agent} のダウンロードに失敗しました: {message}" + }, "unableReadAgentConfig": "エージェント設定を読み取れません: {message}", - "autoLinkFailedTitle": "{agent} の自動リンクに失敗しました", - "preflightCheckFailedDefault": "事前チェックに失敗しました。エージェント設定を確認してください。", - "preflightFailedTitle": "{agent} の事前チェックに失敗しました", - "autoLinkPreflightFailed": "自動リンクの事前チェックに失敗しました: {message}", "connectFailedTitle": "{agent} の接続に失敗しました", "toolFallbackTitle": "ツール", "eventErrorTitle": "エージェントエラー", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index e1487e9..5b30ea0 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -851,7 +851,6 @@ "connection": { "connected": "연결됨", "connecting": "연결 중...", - "downloading": "다운로드 중...", "prompting": "응답 중...", "error": "연결 오류", "disconnected": "연결 끊김", @@ -1422,11 +1421,13 @@ "unavailable": "{agent}은(는) 현재 플랫폼에서 사용할 수 없습니다.", "sdkMissing": "{agent} SDK가 설치되어 있지 않습니다" }, + "backendErrors": { + "initializeTimeout": "{agent} 연결 핸드셰이크가 시간 초과되었습니다(60초 동안 응답 없음). 설정을 열어 에이전트 및 네트워크 구성을 확인하세요.", + "processExited": "{agent} 프로세스가 예기치 않게 종료되었습니다.", + "spawnFailed": "{agent} 시작 실패: {message}", + "downloadFailed": "{agent} 다운로드 실패: {message}" + }, "unableReadAgentConfig": "에이전트 구성을 읽을 수 없습니다: {message}", - "autoLinkFailedTitle": "{agent} 자동 연결 실패", - "preflightCheckFailedDefault": "사전 점검에 실패했습니다. 에이전트 설정을 확인하세요.", - "preflightFailedTitle": "{agent} 사전 점검 실패", - "autoLinkPreflightFailed": "자동 연결 사전 점검 실패: {message}", "connectFailedTitle": "{agent} 연결 실패", "toolFallbackTitle": "도구", "eventErrorTitle": "에이전트 오류", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index aafbd0f..d2db0a9 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -851,7 +851,6 @@ "connection": { "connected": "Conectado", "connecting": "Conectando...", - "downloading": "Baixando...", "prompting": "Respondendo...", "error": "Erro de conexão", "disconnected": "Desconectado", @@ -1422,11 +1421,13 @@ "unavailable": "{agent} está indisponível na plataforma atual.", "sdkMissing": "O SDK de {agent} não está instalado" }, + "backendErrors": { + "initializeTimeout": "O handshake de conexão de {agent} expirou (sem resposta após 60 segundos). Abra Configurações para verificar a configuração do agente e da rede.", + "processExited": "O processo de {agent} encerrou inesperadamente.", + "spawnFailed": "Falha ao iniciar {agent}: {message}", + "downloadFailed": "Falha no download de {agent}: {message}" + }, "unableReadAgentConfig": "Não foi possível ler a configuração do agente: {message}", - "autoLinkFailedTitle": "Falha no vínculo automático de {agent}", - "preflightCheckFailedDefault": "As verificações de pré-voo falharam. Verifique as configurações de agentes.", - "preflightFailedTitle": "Pré-voo de {agent} falhou", - "autoLinkPreflightFailed": "Pré-voo do vínculo automático falhou: {message}", "connectFailedTitle": "Falha na conexão de {agent}", "toolFallbackTitle": "Ferramenta", "eventErrorTitle": "Erro do agente", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 03f281d..dfe6102 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -851,7 +851,6 @@ "connection": { "connected": "已连接", "connecting": "连接中...", - "downloading": "下载中...", "prompting": "响应中...", "error": "连接异常", "disconnected": "未连接", @@ -1422,11 +1421,13 @@ "unavailable": "{agent} 当前平台不可用。", "sdkMissing": "{agent} SDK 尚未安装" }, + "backendErrors": { + "initializeTimeout": "{agent} 连接握手超时(60 秒未响应),请前往设置页面检查智能体和网络配置。", + "processExited": "{agent} 进程意外退出。", + "spawnFailed": "启动 {agent} 失败:{message}", + "downloadFailed": "{agent} 下载失败:{message}" + }, "unableReadAgentConfig": "无法读取 Agent 配置:{message}", - "autoLinkFailedTitle": "{agent} 自动链接失败", - "preflightCheckFailedDefault": "预检查未通过,请检查 Agent 配置。", - "preflightFailedTitle": "{agent} 预检查失败", - "autoLinkPreflightFailed": "自动链接预检查失败:{message}", "connectFailedTitle": "{agent} 连接失败", "toolFallbackTitle": "工具", "eventErrorTitle": "Agent 错误", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index e6e944d..07c3998 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -851,7 +851,6 @@ "connection": { "connected": "已連線", "connecting": "連線中...", - "downloading": "下載中...", "prompting": "回應中...", "error": "連線異常", "disconnected": "未連線", @@ -1422,11 +1421,13 @@ "unavailable": "{agent} 目前平台不可用。", "sdkMissing": "{agent} SDK 尚未安裝" }, + "backendErrors": { + "initializeTimeout": "{agent} 連線交握逾時(60 秒未回應),請前往設定頁面檢查智能體與網路設定。", + "processExited": "{agent} 處理程序意外結束。", + "spawnFailed": "啟動 {agent} 失敗:{message}", + "downloadFailed": "{agent} 下載失敗:{message}" + }, "unableReadAgentConfig": "無法讀取 Agent 設定:{message}", - "autoLinkFailedTitle": "{agent} 自動連結失敗", - "preflightCheckFailedDefault": "預檢查未通過,請檢查 Agent 設定。", - "preflightFailedTitle": "{agent} 預檢查失敗", - "autoLinkPreflightFailed": "自動連結預檢查失敗:{message}", "connectFailedTitle": "{agent} 連線失敗", "toolFallbackTitle": "工具", "eventErrorTitle": "Agent 錯誤", diff --git a/src/lib/types.ts b/src/lib/types.ts index da63c10..306bc5c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -256,7 +256,6 @@ export const AGENT_COLORS: Record = { // ACP connection status (matches Rust ConnectionStatus) export type ConnectionStatus = | "connecting" - | "downloading" | "connected" | "prompting" | "disconnected" @@ -442,7 +441,14 @@ export type AcpEvent = connection_id: string status: ConnectionStatus } - | { type: "error"; connection_id: string; message: string } + | { + type: "error" + connection_id: string + message: string + agent_type: string + /** Stable backend error identifier for localization (e.g. "initialize_timeout"). */ + code: string | null + } | { type: "available_commands" connection_id: string