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) <noreply@anthropic.com>
This commit is contained in:
@@ -133,6 +133,34 @@ pub fn detect_installed_version(
|
|||||||
installed_version_for_agent(agent_type, cmd_name)
|
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<Option<(PathBuf, String)>, 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 {
|
fn version_cmp(a: &str, b: &str) -> std::cmp::Ordering {
|
||||||
let mut a_parts = parse_version_parts(a);
|
let mut a_parts = parse_version_parts(a);
|
||||||
let mut b_parts = parse_version_parts(b);
|
let mut b_parts = parse_version_parts(b);
|
||||||
|
|||||||
@@ -93,6 +93,41 @@ pub enum ConnectionCommand {
|
|||||||
Disconnect,
|
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<Mutex<_>>` and `String` so the spawned task has `'static`
|
||||||
|
/// captures.
|
||||||
|
struct ConnectionCleanupGuard {
|
||||||
|
connections: Arc<tokio::sync::Mutex<HashMap<String, AgentConnection>>>,
|
||||||
|
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.
|
/// Represents a single active ACP agent connection.
|
||||||
pub struct AgentConnection {
|
pub struct AgentConnection {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -116,8 +151,6 @@ impl AgentConnection {
|
|||||||
async fn build_agent(
|
async fn build_agent(
|
||||||
agent_type: AgentType,
|
agent_type: AgentType,
|
||||||
runtime_env: &BTreeMap<String, String>,
|
runtime_env: &BTreeMap<String, String>,
|
||||||
connection_id: &str,
|
|
||||||
emitter: &EventEmitter,
|
|
||||||
) -> Result<AcpAgent, AcpError> {
|
) -> Result<AcpAgent, AcpError> {
|
||||||
let meta = registry::get_agent_meta(agent_type);
|
let meta = registry::get_agent_meta(agent_type);
|
||||||
debug_assert_eq!(meta.agent_type, 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()))
|
.map_err(|e| AcpError::SpawnFailed(e.to_string()))
|
||||||
}
|
}
|
||||||
AgentDistribution::Binary {
|
AgentDistribution::Binary {
|
||||||
version,
|
version: registry_version,
|
||||||
cmd,
|
cmd,
|
||||||
args,
|
args,
|
||||||
env,
|
env,
|
||||||
platforms,
|
platforms,
|
||||||
} => {
|
} => {
|
||||||
let platform = registry::current_platform();
|
let platform = registry::current_platform();
|
||||||
let info = platforms
|
let _ = platforms
|
||||||
.iter()
|
.iter()
|
||||||
.find(|p| p.platform == platform)
|
.find(|p| p.platform == platform)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -199,33 +232,49 @@ async fn build_agent(
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let has_cached_binary =
|
// Session-page connect must never trigger a download. Use
|
||||||
crate::acp::binary_cache::find_cached_binary_for_agent(agent_type, version, cmd)
|
// the best cached version available (tolerates users on
|
||||||
.ok()
|
// older-but-still-working binaries); return SdkNotInstalled
|
||||||
.flatten()
|
// only when nothing is cached, so the frontend can prompt
|
||||||
.is_some();
|
// the user to install it from the Agent Settings page.
|
||||||
if !has_cached_binary {
|
//
|
||||||
crate::web::event_bridge::emit_event(
|
// INVARIANT: the substring "is not installed" is matched
|
||||||
emitter,
|
// verbatim by the frontend catch block in
|
||||||
"acp://event",
|
// `src/contexts/acp-connections-context.tsx` to surface a
|
||||||
AcpEvent::StatusChanged {
|
// localized install prompt. Do not change the wording.
|
||||||
connection_id: connection_id.into(),
|
let (binary_path, cached_version) =
|
||||||
status: ConnectionStatus::Downloading,
|
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_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 mut server = McpServerStdio::new(meta.name, &binary_str);
|
||||||
let cmd_args: Vec<String> = args.iter().map(|a| (*a).to_string()).collect();
|
let cmd_args: Vec<String> = args.iter().map(|a| (*a).to_string()).collect();
|
||||||
|
let cmd_args_for_log = cmd_args.clone();
|
||||||
if !cmd_args.is_empty() {
|
if !cmd_args.is_empty() {
|
||||||
server = server.args(cmd_args);
|
server = server.args(cmd_args);
|
||||||
}
|
}
|
||||||
let merged_env = merge_agent_env(env, runtime_env);
|
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() {
|
if !merged_env.is_empty() {
|
||||||
let env_vars: Vec<sacp::schema::EnvVariable> = merged_env
|
let env_vars: Vec<sacp::schema::EnvVariable> = merged_env
|
||||||
.iter()
|
.iter()
|
||||||
@@ -233,11 +282,63 @@ async fn build_agent(
|
|||||||
.collect();
|
.collect();
|
||||||
server = server.env(env_vars);
|
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();
|
let agent_name = meta.name.to_string();
|
||||||
Ok(AcpAgent::new(sacp::schema::McpServer::Stdio(server)).with_debug(
|
Ok(AcpAgent::new(sacp::schema::McpServer::Stdio(server)).with_debug(
|
||||||
move |line, dir| {
|
move |line, dir| {
|
||||||
if dir == sacp_tokio::LineDirection::Stderr {
|
let (tag, enabled) = match dir {
|
||||||
eprintln!("[ACP][{agent_name}][stderr] {line}");
|
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}] {}... <truncated {} bytes>",
|
||||||
|
&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.
|
/// 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(
|
pub async fn spawn_agent_connection(
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
agent_type: AgentType,
|
agent_type: AgentType,
|
||||||
@@ -254,7 +362,8 @@ pub async fn spawn_agent_connection(
|
|||||||
runtime_env: BTreeMap<String, String>,
|
runtime_env: BTreeMap<String, String>,
|
||||||
owner_window_label: String,
|
owner_window_label: String,
|
||||||
emitter: EventEmitter,
|
emitter: EventEmitter,
|
||||||
) -> Result<AgentConnection, AcpError> {
|
connections: Arc<tokio::sync::Mutex<HashMap<String, AgentConnection>>>,
|
||||||
|
) -> Result<(), AcpError> {
|
||||||
crate::web::event_bridge::emit_event(
|
crate::web::event_bridge::emit_event(
|
||||||
&emitter,
|
&emitter,
|
||||||
"acp://event",
|
"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::<ConnectionCommand>(32);
|
let (cmd_tx, cmd_rx) = mpsc::channel::<ConnectionCommand>(32);
|
||||||
let conn_id = connection_id.clone();
|
let conn_id = connection_id.clone();
|
||||||
let emitter_clone = emitter.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 {
|
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(
|
let result = run_connection(
|
||||||
agent,
|
agent,
|
||||||
conn_id.clone(),
|
conn_id.clone(),
|
||||||
@@ -283,6 +415,7 @@ pub async fn spawn_agent_connection(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
|
let code = e.code().map(String::from);
|
||||||
crate::web::event_bridge::emit_event(
|
crate::web::event_bridge::emit_event(
|
||||||
&emitter_clone,
|
&emitter_clone,
|
||||||
"acp://event",
|
"acp://event",
|
||||||
@@ -290,6 +423,7 @@ pub async fn spawn_agent_connection(
|
|||||||
connection_id: conn_id.clone(),
|
connection_id: conn_id.clone(),
|
||||||
message: e.to_string(),
|
message: e.to_string(),
|
||||||
agent_type: agent_type.to_string(),
|
agent_type: agent_type.to_string(),
|
||||||
|
code,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -302,15 +436,11 @@ pub async fn spawn_agent_connection(
|
|||||||
status: ConnectionStatus::Disconnected,
|
status: ConnectionStatus::Disconnected,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// `_cleanup` is dropped here — removes the connection entry from
|
||||||
|
// the manager map. Same drop semantics apply on panic unwinding.
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(AgentConnection {
|
Ok(())
|
||||||
id: connection_id,
|
|
||||||
agent_type,
|
|
||||||
status: ConnectionStatus::Connecting,
|
|
||||||
owner_window_label,
|
|
||||||
cmd_tx,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared state for pending permission responders.
|
/// Shared state for pending permission responders.
|
||||||
@@ -699,6 +829,8 @@ async fn run_connection(
|
|||||||
on_receive_request!(),
|
on_receive_request!(),
|
||||||
)
|
)
|
||||||
.connect_with(agent, async move |cx| -> Result<(), sacp::Error> {
|
.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.
|
// Advertise filesystem + terminal capabilities for ACP tool execution.
|
||||||
let init_request = InitializeRequest::new(ProtocolVersion::LATEST).client_capabilities(
|
let init_request = InitializeRequest::new(ProtocolVersion::LATEST).client_capabilities(
|
||||||
ClientCapabilities::new()
|
ClientCapabilities::new()
|
||||||
@@ -707,7 +839,52 @@ async fn run_connection(
|
|||||||
.read_text_file(true)
|
.read_text_file(true)
|
||||||
.write_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(
|
emit_prompt_capabilities(
|
||||||
&conn_id,
|
&conn_id,
|
||||||
&emitter_clone,
|
&emitter_clone,
|
||||||
@@ -860,6 +1037,7 @@ async fn run_connection(
|
|||||||
"Failed to load session, starting new: {e}"
|
"Failed to load session, starting new: {e}"
|
||||||
),
|
),
|
||||||
agent_type: agent_type.to_string(),
|
agent_type: agent_type.to_string(),
|
||||||
|
code: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -968,7 +1146,14 @@ async fn run_connection(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.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.
|
/// Store the permission responder and emit event to frontend.
|
||||||
@@ -1614,6 +1799,7 @@ async fn run_conversation_loop<'a>(
|
|||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
message: "Prompt must contain at least one content block".into(),
|
message: "Prompt must contain at least one content block".into(),
|
||||||
agent_type: agent_type.to_string(),
|
agent_type: agent_type.to_string(),
|
||||||
|
code: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -1802,6 +1988,7 @@ async fn run_conversation_loop<'a>(
|
|||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
message: format!("Failed to set mode: {e}"),
|
message: format!("Failed to set mode: {e}"),
|
||||||
agent_type: agent_type.to_string(),
|
agent_type: agent_type.to_string(),
|
||||||
|
code: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1829,6 +2016,7 @@ async fn run_conversation_loop<'a>(
|
|||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
message: format!("Failed to set config option: {e}"),
|
message: format!("Failed to set config option: {e}"),
|
||||||
agent_type: agent_type.to_string(),
|
agent_type: agent_type.to_string(),
|
||||||
|
code: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1938,6 +2126,7 @@ async fn run_conversation_loop<'a>(
|
|||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
message: format!("Failed to set mode: {e}"),
|
message: format!("Failed to set mode: {e}"),
|
||||||
agent_type: agent_type.to_string(),
|
agent_type: agent_type.to_string(),
|
||||||
|
code: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1958,6 +2147,7 @@ async fn run_conversation_loop<'a>(
|
|||||||
connection_id: conn_id.into(),
|
connection_id: conn_id.into(),
|
||||||
message: format!("Failed to set config option: {e}"),
|
message: format!("Failed to set config option: {e}"),
|
||||||
agent_type: agent_type.to_string(),
|
agent_type: agent_type.to_string(),
|
||||||
|
code: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ pub enum AcpError {
|
|||||||
DownloadFailed(String),
|
DownloadFailed(String),
|
||||||
#[error("platform not supported: {0}")]
|
#[error("platform not supported: {0}")]
|
||||||
PlatformNotSupported(String),
|
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 {
|
impl AcpError {
|
||||||
@@ -30,6 +34,25 @@ impl AcpError {
|
|||||||
|
|
||||||
Self::Protocol(sanitized)
|
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 {
|
impl Serialize for AcpError {
|
||||||
|
|||||||
@@ -42,7 +42,11 @@ impl ConnectionManager {
|
|||||||
connection_id, owner_window_label, agent_type
|
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(),
|
connection_id.clone(),
|
||||||
agent_type,
|
agent_type,
|
||||||
working_dir,
|
working_dir,
|
||||||
@@ -50,14 +54,10 @@ impl ConnectionManager {
|
|||||||
runtime_env,
|
runtime_env,
|
||||||
owner_window_label,
|
owner_window_label,
|
||||||
emitter,
|
emitter,
|
||||||
|
self.connections.clone(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
self.connections
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.insert(connection_id.clone(), conn);
|
|
||||||
|
|
||||||
Ok(connection_id)
|
Ok(connection_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -299,22 +299,41 @@ async fn check_binary_environment(
|
|||||||
};
|
};
|
||||||
checks.push(platform_check);
|
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 {
|
if platform_supported {
|
||||||
let cache_check = match binary_cache::find_cached_binary_for_agent(agent_type, version, cmd)
|
let cache_check =
|
||||||
{
|
match binary_cache::find_best_cached_binary_for_agent(agent_type, cmd) {
|
||||||
Ok(Some(_)) => CheckItem {
|
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(),
|
check_id: "binary_cached".into(),
|
||||||
label: "Binary cache".into(),
|
label: "Binary cache".into(),
|
||||||
status: CheckStatus::Pass,
|
status: CheckStatus::Pass,
|
||||||
message: "Binary is cached locally".into(),
|
message,
|
||||||
fixes: vec![],
|
fixes: vec![],
|
||||||
},
|
}
|
||||||
|
}
|
||||||
Ok(None) => CheckItem {
|
Ok(None) => CheckItem {
|
||||||
check_id: "binary_cached".into(),
|
check_id: "binary_cached".into(),
|
||||||
label: "Binary cache".into(),
|
label: "Binary cache".into(),
|
||||||
status: CheckStatus::Warn,
|
status: CheckStatus::Warn,
|
||||||
message: "Binary not cached yet, will be downloaded on first connection".into(),
|
message:
|
||||||
|
"Binary is not installed. Download it from Agent Settings before connecting."
|
||||||
|
.into(),
|
||||||
fixes: vec![],
|
fixes: vec![],
|
||||||
},
|
},
|
||||||
Err(_) => CheckItem {
|
Err(_) => CheckItem {
|
||||||
|
|||||||
@@ -131,6 +131,10 @@ pub enum AcpEvent {
|
|||||||
connection_id: String,
|
connection_id: String,
|
||||||
message: String,
|
message: String,
|
||||||
agent_type: 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<String>,
|
||||||
},
|
},
|
||||||
/// Available slash commands updated
|
/// Available slash commands updated
|
||||||
AvailableCommands {
|
AvailableCommands {
|
||||||
@@ -212,7 +216,6 @@ pub struct PlanEntryInfo {
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ConnectionStatus {
|
pub enum ConnectionStatus {
|
||||||
Connecting,
|
Connecting,
|
||||||
Downloading,
|
|
||||||
Connected,
|
Connected,
|
||||||
Prompting,
|
Prompting,
|
||||||
Disconnected,
|
Disconnected,
|
||||||
|
|||||||
@@ -92,6 +92,58 @@ pub(crate) fn is_cmd_available(cmd: &str) -> bool {
|
|||||||
which::which(cmd).is_ok()
|
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
|
/// Detect the actual installed version of an npm global package by running
|
||||||
/// `npm list -g <package_name> --json` and parsing the JSON output.
|
/// `npm list -g <package_name> --json` and parsing the JSON output.
|
||||||
///
|
///
|
||||||
@@ -1798,8 +1850,6 @@ pub async fn acp_connect(
|
|||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
window: tauri::WebviewWindow,
|
window: tauri::WebviewWindow,
|
||||||
) -> Result<String, AcpError> {
|
) -> Result<String, AcpError> {
|
||||||
let meta = registry::get_agent_meta(agent_type);
|
|
||||||
|
|
||||||
let setting = agent_setting_service::get_by_agent_type(&db.conn, agent_type)
|
let setting = agent_setting_service::get_by_agent_type(&db.conn, agent_type)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AcpError::protocol(e.to_string()))?;
|
.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());
|
runtime_env.insert("OPENCLAW_RESET_SESSION".into(), "1".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let registry::AgentDistribution::Npx { cmd, .. } = meta.distribution {
|
// Guard: the session page must never trigger a download or install.
|
||||||
if !is_cmd_available(cmd) {
|
// If the agent isn't ready, return SdkNotInstalled here so the frontend
|
||||||
return Err(AcpError::protocol(format!(
|
// can prompt the user to install it from Agent Settings.
|
||||||
"{} SDK is not installed. Please install it in Agent Settings.",
|
verify_agent_installed(agent_type)?;
|
||||||
meta.name
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let emitter = EventEmitter::Tauri(app_handle);
|
let emitter = EventEmitter::Tauri(app_handle);
|
||||||
manager
|
manager
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use axum::{extract::Extension, Json};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::acp::preflight::PreflightResult;
|
use crate::acp::preflight::PreflightResult;
|
||||||
use crate::acp::registry;
|
|
||||||
use crate::acp::types::{
|
use crate::acp::types::{
|
||||||
AcpAgentInfo, AcpAgentStatus, AgentSkillContent, AgentSkillLayout, AgentSkillScope,
|
AcpAgentInfo, AcpAgentStatus, AgentSkillContent, AgentSkillLayout, AgentSkillScope,
|
||||||
AgentSkillsListResult, ConnectionInfo, ForkResultInfo,
|
AgentSkillsListResult, ConnectionInfo, ForkResultInfo,
|
||||||
@@ -57,7 +56,6 @@ pub async fn acp_connect(
|
|||||||
) -> Result<Json<String>, AppCommandError> {
|
) -> Result<Json<String>, AppCommandError> {
|
||||||
let db = &state.db;
|
let db = &state.db;
|
||||||
let manager = &state.connection_manager;
|
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)
|
let setting = agent_setting_service::get_by_agent_type(&db.conn, params.agent_type)
|
||||||
.await
|
.await
|
||||||
@@ -93,14 +91,11 @@ pub async fn acp_connect(
|
|||||||
runtime_env.insert("OPENCLAW_RESET_SESSION".into(), "1".into());
|
runtime_env.insert("OPENCLAW_RESET_SESSION".into(), "1".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let registry::AgentDistribution::Npx { cmd, .. } = meta.distribution {
|
// Guard: the session page must never trigger a download or install.
|
||||||
if !acp_commands::is_cmd_available(cmd) {
|
// If the agent isn't ready, return SdkNotInstalled here so the frontend
|
||||||
return Err(AppCommandError::task_execution_failed(format!(
|
// can prompt the user to install it from Agent Settings.
|
||||||
"{} SDK is not installed. Please install it in Agent Settings.",
|
acp_commands::verify_agent_installed(params.agent_type)
|
||||||
meta.name
|
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?;
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let emitter = state.emitter.clone();
|
let emitter = state.emitter.clone();
|
||||||
let connection_id = manager
|
let connection_id = manager
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function ChatInput({
|
|||||||
const t = useTranslations("Folder.chat.chatInput")
|
const t = useTranslations("Folder.chat.chatInput")
|
||||||
const isConnected = status === "connected"
|
const isConnected = status === "connected"
|
||||||
const isPrompting = status === "prompting"
|
const isPrompting = status === "prompting"
|
||||||
const isConnecting = status === "connecting" || status === "downloading"
|
const isConnecting = status === "connecting"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 pt-0">
|
<div className="p-4 pt-0">
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ function normalizeErrorMessage(error: unknown): string {
|
|||||||
return String(error)
|
return String(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExpectedAutoLinkError(error: unknown): boolean {
|
function isExpectedConnectError(error: unknown): boolean {
|
||||||
if (!error || typeof error !== "object") return false
|
if (!error || typeof error !== "object") return false
|
||||||
return (error as { alerted?: unknown }).alerted === true
|
return (error as { alerted?: unknown }).alerted === true
|
||||||
}
|
}
|
||||||
@@ -310,8 +310,7 @@ const ConversationTabView = memo(function ConversationTabView({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connStatusRef.current = connStatus
|
connStatusRef.current = connStatus
|
||||||
}, [connStatus])
|
}, [connStatus])
|
||||||
const isConnecting =
|
const isConnecting = connStatus === "connecting"
|
||||||
connStatus === "connecting" || connStatus === "downloading"
|
|
||||||
const connectionModes = useMemo(
|
const connectionModes = useMemo(
|
||||||
() => conn.modes?.available_modes ?? [],
|
() => conn.modes?.available_modes ?? [],
|
||||||
[conn.modes?.available_modes]
|
[conn.modes?.available_modes]
|
||||||
@@ -756,15 +755,13 @@ const ConversationTabView = memo(function ConversationTabView({
|
|||||||
const s = connStatusRef.current
|
const s = connStatusRef.current
|
||||||
const doConnect = () => {
|
const doConnect = () => {
|
||||||
if (!workingDirForConnection) return
|
if (!workingDirForConnection) return
|
||||||
connConnect(nextAgentType, workingDirForConnection, undefined, {
|
connConnect(nextAgentType, workingDirForConnection, undefined)
|
||||||
source: "auto_link",
|
|
||||||
})
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setAgentConnectError(null)
|
setAgentConnectError(null)
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
setAgentConnectError(normalizeErrorMessage(e))
|
setAgentConnectError(normalizeErrorMessage(e))
|
||||||
if (!isExpectedAutoLinkError(e)) {
|
if (!isExpectedConnectError(e)) {
|
||||||
console.error("[ConversationTabView] switch agent:", e)
|
console.error("[ConversationTabView] switch agent:", e)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { cn } from "@/lib/utils"
|
|||||||
type ConnectionStatusLabelKey =
|
type ConnectionStatusLabelKey =
|
||||||
| "connected"
|
| "connected"
|
||||||
| "connecting"
|
| "connecting"
|
||||||
| "downloading"
|
|
||||||
| "prompting"
|
| "prompting"
|
||||||
| "error"
|
| "error"
|
||||||
|
|
||||||
@@ -32,10 +31,6 @@ const STATUS_STYLE: Record<
|
|||||||
className: "opacity-100 animate-pulse",
|
className: "opacity-100 animate-pulse",
|
||||||
labelKey: "connecting",
|
labelKey: "connecting",
|
||||||
},
|
},
|
||||||
downloading: {
|
|
||||||
className: "opacity-100 animate-pulse",
|
|
||||||
labelKey: "downloading",
|
|
||||||
},
|
|
||||||
prompting: {
|
prompting: {
|
||||||
className: "opacity-100 animate-pulse",
|
className: "opacity-100 animate-pulse",
|
||||||
labelKey: "prompting",
|
labelKey: "prompting",
|
||||||
|
|||||||
@@ -1106,19 +1106,12 @@ export function useConnectionStore(): ConnectionStoreApi {
|
|||||||
|
|
||||||
// ── Actions context (unchanged interface) ──
|
// ── Actions context (unchanged interface) ──
|
||||||
|
|
||||||
export type ConnectSource = "manual" | "auto_link"
|
|
||||||
|
|
||||||
export interface ConnectOptions {
|
|
||||||
source?: ConnectSource
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AcpActionsValue {
|
export interface AcpActionsValue {
|
||||||
connect(
|
connect(
|
||||||
contextKey: string,
|
contextKey: string,
|
||||||
agentType: AgentType,
|
agentType: AgentType,
|
||||||
workingDir?: string,
|
workingDir?: string,
|
||||||
sessionId?: string,
|
sessionId?: string
|
||||||
options?: ConnectOptions
|
|
||||||
): Promise<void>
|
): Promise<void>
|
||||||
disconnect(contextKey: string): Promise<void>
|
disconnect(contextKey: string): Promise<void>
|
||||||
disconnectAll(): Promise<void>
|
disconnectAll(): Promise<void>
|
||||||
@@ -1211,7 +1204,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
// Keys whose disconnect was requested while connect was still in flight
|
// Keys whose disconnect was requested while connect was still in flight
|
||||||
const abandonedKeysRef = useRef(new Set<string>())
|
const abandonedKeysRef = useRef(new Set<string>())
|
||||||
|
|
||||||
type AutoLinkBlockState =
|
type ConnectBlockState =
|
||||||
| { kind: "none"; reason: "" }
|
| { kind: "none"; reason: "" }
|
||||||
| {
|
| {
|
||||||
kind: "missing_config" | "disabled" | "unavailable" | "sdk_missing"
|
kind: "missing_config" | "disabled" | "unavailable" | "sdk_missing"
|
||||||
@@ -1236,8 +1229,8 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
[t]
|
[t]
|
||||||
)
|
)
|
||||||
|
|
||||||
const resolveAutoLinkBlockState = useCallback(
|
const resolveConnectBlockState = useCallback(
|
||||||
(agent: AcpAgentStatus | null): AutoLinkBlockState => {
|
(agent: AcpAgentStatus | null): ConnectBlockState => {
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return { kind: "missing_config", reason: t("blocked.missingConfig") }
|
return { kind: "missing_config", reason: t("blocked.missingConfig") }
|
||||||
}
|
}
|
||||||
@@ -1714,24 +1707,55 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
case "error": {
|
case "error": {
|
||||||
flushStreamingQueue()
|
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)
|
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,
|
||||||
|
})
|
||||||
|
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) {
|
if (nc) {
|
||||||
const agentLabel = AGENT_LABELS[nc.agentType]
|
|
||||||
const fn = folderNameRef.current
|
const fn = folderNameRef.current
|
||||||
const title = fn ? `${fn} - Codeg` : "Codeg"
|
const title = fn ? `${fn} - Codeg` : "Codeg"
|
||||||
sendSystemNotification(
|
sendSystemNotification(
|
||||||
title,
|
title,
|
||||||
t("notificationError", {
|
t("notificationError", {
|
||||||
agent: agentLabel,
|
agent: agentLabel,
|
||||||
message: e.message,
|
message: localizedMessage,
|
||||||
})
|
})
|
||||||
).catch(() => {})
|
).catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "available_commands":
|
case "available_commands":
|
||||||
@@ -1821,11 +1845,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
for (const [contextKey, conn] of storeRef.current.connections) {
|
for (const [contextKey, conn] of storeRef.current.connections) {
|
||||||
if (contextKey === currentActiveKey) continue
|
if (contextKey === currentActiveKey) continue
|
||||||
if (currentOpenTabKeys.has(contextKey)) continue
|
if (currentOpenTabKeys.has(contextKey)) continue
|
||||||
if (
|
if (conn.status === "prompting" || conn.status === "connecting") {
|
||||||
conn.status === "prompting" ||
|
|
||||||
conn.status === "connecting" ||
|
|
||||||
conn.status === "downloading"
|
|
||||||
) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (conn.status !== "connected") continue
|
if (conn.status !== "connected") continue
|
||||||
@@ -1865,17 +1885,16 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
contextKey: string,
|
contextKey: string,
|
||||||
agentType: AgentType,
|
agentType: AgentType,
|
||||||
workingDir?: string,
|
workingDir?: string,
|
||||||
sessionId?: string,
|
sessionId?: string
|
||||||
options?: ConnectOptions
|
|
||||||
) => {
|
) => {
|
||||||
const source = options?.source ?? "manual"
|
|
||||||
const isAutoLink = source === "auto_link"
|
|
||||||
|
|
||||||
if (connectingKeysRef.current.has(contextKey)) return
|
if (connectingKeysRef.current.has(contextKey)) return
|
||||||
connectingKeysRef.current.add(contextKey)
|
connectingKeysRef.current.add(contextKey)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isAutoLink) {
|
// 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
|
let configuredAgent: AcpAgentStatus | null = null
|
||||||
try {
|
try {
|
||||||
configuredAgent = await acpGetAgentStatus(agentType)
|
configuredAgent = await acpGetAgentStatus(agentType)
|
||||||
@@ -1883,21 +1902,21 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
const reason = t("unableReadAgentConfig", {
|
const reason = t("unableReadAgentConfig", {
|
||||||
message: normalizeErrorMessage(error),
|
message: normalizeErrorMessage(error),
|
||||||
})
|
})
|
||||||
const autoLinkFailedTitle = t("autoLinkFailedTitle", {
|
const failedTitle = t("connectFailedTitle", {
|
||||||
agent: AGENT_LABELS[agentType],
|
agent: AGENT_LABELS[agentType],
|
||||||
})
|
})
|
||||||
pushAlertRef.current(
|
pushAlertRef.current(
|
||||||
"error",
|
"error",
|
||||||
autoLinkFailedTitle,
|
failedTitle,
|
||||||
`${reason}\n${t("agentsSetupHint")}`,
|
`${reason}\n${t("agentsSetupHint")}`,
|
||||||
[buildOpenAgentsSettingsAction(agentType)]
|
[buildOpenAgentsSettingsAction(agentType)]
|
||||||
)
|
)
|
||||||
throw createAlertedError(reason)
|
throw createAlertedError(reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocked = resolveAutoLinkBlockState(configuredAgent)
|
const blocked = resolveConnectBlockState(configuredAgent)
|
||||||
if (blocked.kind !== "none") {
|
if (blocked.kind !== "none") {
|
||||||
const autoLinkFailedTitle = t("autoLinkFailedTitle", {
|
const failedTitle = t("connectFailedTitle", {
|
||||||
agent: AGENT_LABELS[agentType],
|
agent: AGENT_LABELS[agentType],
|
||||||
})
|
})
|
||||||
const detail =
|
const detail =
|
||||||
@@ -1909,16 +1928,11 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
: `${blocked.reason}\n${t("agentsSetupHint")}`
|
: `${blocked.reason}\n${t("agentsSetupHint")}`
|
||||||
pushAlertRef.current(
|
pushAlertRef.current(
|
||||||
"error",
|
"error",
|
||||||
blocked.kind === "sdk_missing"
|
blocked.kind === "sdk_missing" ? blocked.reason : failedTitle,
|
||||||
? blocked.reason
|
|
||||||
: autoLinkFailedTitle,
|
|
||||||
detail,
|
detail,
|
||||||
[buildOpenAgentsSettingsAction(agentType)]
|
[buildOpenAgentsSettingsAction(agentType)]
|
||||||
)
|
)
|
||||||
throw createAlertedError(
|
throw createAlertedError(blocked.reason)
|
||||||
blocked.kind === "sdk_missing" ? blocked.reason : blocked.reason
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = storeRef.current.connections.get(contextKey)
|
const existing = storeRef.current.connections.get(contextKey)
|
||||||
@@ -1970,12 +1984,38 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAlertedError(err)) {
|
if (!isAlertedError(err)) {
|
||||||
const message = normalizeErrorMessage(err)
|
const message = normalizeErrorMessage(err)
|
||||||
|
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(
|
pushAlertRef.current(
|
||||||
"error",
|
"error",
|
||||||
t("connectFailedTitle", { agent: agentType }),
|
t("blocked.sdkMissing", { agent: agentLabel }),
|
||||||
|
t("agentsSetupHint"),
|
||||||
|
[buildOpenAgentsSettingsAction(agentType)]
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
pushAlertRef.current(
|
||||||
|
"error",
|
||||||
|
t("connectFailedTitle", { agent: agentLabel }),
|
||||||
message
|
message
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
throw err
|
throw err
|
||||||
} finally {
|
} finally {
|
||||||
connectingKeysRef.current.delete(contextKey)
|
connectingKeysRef.current.delete(contextKey)
|
||||||
@@ -1987,7 +2027,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
consumeBufferedEvents,
|
consumeBufferedEvents,
|
||||||
dispatch,
|
dispatch,
|
||||||
handleMappedEvent,
|
handleMappedEvent,
|
||||||
resolveAutoLinkBlockState,
|
resolveConnectBlockState,
|
||||||
t,
|
t,
|
||||||
waitForListenerReady,
|
waitForListenerReady,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function normalizeErrorMessage(error: unknown): string {
|
|||||||
return String(error)
|
return String(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExpectedAutoLinkError(error: unknown): boolean {
|
function isExpectedConnectError(error: unknown): boolean {
|
||||||
if (!error || typeof error !== "object") return false
|
if (!error || typeof error !== "object") return false
|
||||||
return (error as { alerted?: unknown }).alerted === true
|
return (error as { alerted?: unknown }).alerted === true
|
||||||
}
|
}
|
||||||
@@ -77,12 +77,10 @@ export function useConnectionLifecycle({
|
|||||||
const modeLoading =
|
const modeLoading =
|
||||||
!hasCachedSelectors &&
|
!hasCachedSelectors &&
|
||||||
(status === "connecting" ||
|
(status === "connecting" ||
|
||||||
status === "downloading" ||
|
|
||||||
(isInteractiveStatus && !effectiveSelectorsReady))
|
(isInteractiveStatus && !effectiveSelectorsReady))
|
||||||
const configOptionsLoading =
|
const configOptionsLoading =
|
||||||
!hasCachedSelectors &&
|
!hasCachedSelectors &&
|
||||||
(status === "connecting" ||
|
(status === "connecting" ||
|
||||||
status === "downloading" ||
|
|
||||||
(isInteractiveStatus && !effectiveSelectorsReady))
|
(isInteractiveStatus && !effectiveSelectorsReady))
|
||||||
// Gate for send button: block until the backend session is fully
|
// Gate for send button: block until the backend session is fully
|
||||||
// initialized (selectorsReady from the real backend event, not cache).
|
// initialized (selectorsReady from the real backend event, not cache).
|
||||||
@@ -141,9 +139,7 @@ export function useConnectionLifecycle({
|
|||||||
const s = statusRef.current
|
const s = statusRef.current
|
||||||
if (!s || s === "disconnected" || s === "error") {
|
if (!s || s === "disconnected" || s === "error") {
|
||||||
connConnectRef
|
connConnectRef
|
||||||
.current(agentTypeRef.current, workingDir, sessionIdRef.current, {
|
.current(agentTypeRef.current, workingDir, sessionIdRef.current)
|
||||||
source: "auto_link",
|
|
||||||
})
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setLastAutoConnectError(null)
|
setLastAutoConnectError(null)
|
||||||
@@ -157,7 +153,7 @@ export function useConnectionLifecycle({
|
|||||||
message: normalizeErrorMessage(e),
|
message: normalizeErrorMessage(e),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!isExpectedAutoLinkError(e)) {
|
if (!isExpectedConnectError(e)) {
|
||||||
console.error("[ConnLifecycle] auto-connect:", e)
|
console.error("[ConnLifecycle] auto-connect:", e)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -170,7 +166,7 @@ export function useConnectionLifecycle({
|
|||||||
// Manage task status for connection progress
|
// Manage task status for connection progress
|
||||||
const taskIdRef = useRef<string | null>(null)
|
const taskIdRef = useRef<string | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "connecting" || status === "downloading") {
|
if (status === "connecting") {
|
||||||
if (!taskIdRef.current) {
|
if (!taskIdRef.current) {
|
||||||
const id = `acp-connect-${Date.now()}`
|
const id = `acp-connect-${Date.now()}`
|
||||||
taskIdRef.current = id
|
taskIdRef.current = id
|
||||||
@@ -271,10 +267,8 @@ export function useConnectionLifecycle({
|
|||||||
touchActivity(contextKey)
|
touchActivity(contextKey)
|
||||||
if (!status || status === "disconnected" || status === "error") {
|
if (!status || status === "disconnected" || status === "error") {
|
||||||
setLastAutoConnectError(null)
|
setLastAutoConnectError(null)
|
||||||
connConnect(agentType, workingDir, sessionId, {
|
connConnect(agentType, workingDir, sessionId).catch((e: unknown) => {
|
||||||
source: "auto_link",
|
if (!isExpectedConnectError(e)) {
|
||||||
}).catch((e: unknown) => {
|
|
||||||
if (!isExpectedAutoLinkError(e)) {
|
|
||||||
console.error("[ConnLifecycle] connect:", e)
|
console.error("[ConnLifecycle] connect:", e)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
useConnectionStore,
|
useConnectionStore,
|
||||||
getCachedSelectors,
|
getCachedSelectors,
|
||||||
type ConnectionState,
|
type ConnectionState,
|
||||||
type ConnectOptions,
|
|
||||||
type LiveMessage,
|
type LiveMessage,
|
||||||
type PendingPermission,
|
type PendingPermission,
|
||||||
type PendingQuestion,
|
type PendingQuestion,
|
||||||
@@ -45,8 +44,7 @@ export interface UseConnectionReturn {
|
|||||||
connect: (
|
connect: (
|
||||||
agentType: AgentType,
|
agentType: AgentType,
|
||||||
workingDir?: string,
|
workingDir?: string,
|
||||||
sessionId?: string,
|
sessionId?: string
|
||||||
options?: ConnectOptions
|
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
disconnect: () => Promise<void>
|
disconnect: () => Promise<void>
|
||||||
sendPrompt: (blocks: PromptInputBlock[]) => Promise<void>
|
sendPrompt: (blocks: PromptInputBlock[]) => Promise<void>
|
||||||
@@ -96,12 +94,8 @@ export function useConnection(contextKey: string): UseConnectionReturn {
|
|||||||
const error = connection?.error ?? null
|
const error = connection?.error ?? null
|
||||||
|
|
||||||
const connect = useCallback(
|
const connect = useCallback(
|
||||||
(
|
(agentType: AgentType, workingDir?: string, sessionId?: string) =>
|
||||||
agentType: AgentType,
|
actions.connect(contextKey, agentType, workingDir, sessionId),
|
||||||
workingDir?: string,
|
|
||||||
sessionId?: string,
|
|
||||||
options?: ConnectOptions
|
|
||||||
) => actions.connect(contextKey, agentType, workingDir, sessionId, options),
|
|
||||||
[actions, contextKey]
|
[actions, contextKey]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -851,7 +851,6 @@
|
|||||||
"connection": {
|
"connection": {
|
||||||
"connected": "متصل",
|
"connected": "متصل",
|
||||||
"connecting": "جارٍ الاتصال...",
|
"connecting": "جارٍ الاتصال...",
|
||||||
"downloading": "جارٍ التنزيل...",
|
|
||||||
"prompting": "جارٍ الرد...",
|
"prompting": "جارٍ الرد...",
|
||||||
"error": "خطأ في الاتصال",
|
"error": "خطأ في الاتصال",
|
||||||
"disconnected": "غير متصل",
|
"disconnected": "غير متصل",
|
||||||
@@ -1422,11 +1421,13 @@
|
|||||||
"unavailable": "{agent} غير متاح على المنصة الحالية.",
|
"unavailable": "{agent} غير متاح على المنصة الحالية.",
|
||||||
"sdkMissing": "لم يتم تثبيت SDK الخاص بـ {agent}"
|
"sdkMissing": "لم يتم تثبيت SDK الخاص بـ {agent}"
|
||||||
},
|
},
|
||||||
|
"backendErrors": {
|
||||||
|
"initializeTimeout": "انتهت مهلة مصافحة اتصال {agent} (لا استجابة بعد 60 ثانية). افتح الإعدادات للتحقق من إعدادات الوكيل والشبكة.",
|
||||||
|
"processExited": "انتهت عملية {agent} بشكل غير متوقع.",
|
||||||
|
"spawnFailed": "تعذر بدء تشغيل {agent}: {message}",
|
||||||
|
"downloadFailed": "فشل تنزيل {agent}: {message}"
|
||||||
|
},
|
||||||
"unableReadAgentConfig": "تعذر قراءة إعدادات الوكيل: {message}",
|
"unableReadAgentConfig": "تعذر قراءة إعدادات الوكيل: {message}",
|
||||||
"autoLinkFailedTitle": "فشل الربط التلقائي لـ {agent}",
|
|
||||||
"preflightCheckFailedDefault": "فشلت فحوصات ما قبل التشغيل. تحقق من إعدادات الوكلاء.",
|
|
||||||
"preflightFailedTitle": "فشل فحص ما قبل التشغيل لـ {agent}",
|
|
||||||
"autoLinkPreflightFailed": "فشل فحص ما قبل التشغيل للربط التلقائي: {message}",
|
|
||||||
"connectFailedTitle": "فشل اتصال {agent}",
|
"connectFailedTitle": "فشل اتصال {agent}",
|
||||||
"toolFallbackTitle": "أداة",
|
"toolFallbackTitle": "أداة",
|
||||||
"eventErrorTitle": "خطأ الوكيل",
|
"eventErrorTitle": "خطأ الوكيل",
|
||||||
|
|||||||
@@ -851,7 +851,6 @@
|
|||||||
"connection": {
|
"connection": {
|
||||||
"connected": "Verbunden",
|
"connected": "Verbunden",
|
||||||
"connecting": "Verbinde...",
|
"connecting": "Verbinde...",
|
||||||
"downloading": "Lade herunter...",
|
|
||||||
"prompting": "Antworte...",
|
"prompting": "Antworte...",
|
||||||
"error": "Verbindungsfehler",
|
"error": "Verbindungsfehler",
|
||||||
"disconnected": "Getrennt",
|
"disconnected": "Getrennt",
|
||||||
@@ -1422,11 +1421,13 @@
|
|||||||
"unavailable": "{agent} ist auf der aktuellen Plattform nicht verfügbar.",
|
"unavailable": "{agent} ist auf der aktuellen Plattform nicht verfügbar.",
|
||||||
"sdkMissing": "{agent} SDK ist nicht installiert"
|
"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}",
|
"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",
|
"connectFailedTitle": "{agent} Verbindung fehlgeschlagen",
|
||||||
"toolFallbackTitle": "Werkzeug",
|
"toolFallbackTitle": "Werkzeug",
|
||||||
"eventErrorTitle": "Agentenfehler",
|
"eventErrorTitle": "Agentenfehler",
|
||||||
|
|||||||
@@ -851,7 +851,6 @@
|
|||||||
"connection": {
|
"connection": {
|
||||||
"connected": "Connected",
|
"connected": "Connected",
|
||||||
"connecting": "Connecting...",
|
"connecting": "Connecting...",
|
||||||
"downloading": "Downloading...",
|
|
||||||
"prompting": "Responding...",
|
"prompting": "Responding...",
|
||||||
"error": "Connection error",
|
"error": "Connection error",
|
||||||
"disconnected": "Disconnected",
|
"disconnected": "Disconnected",
|
||||||
@@ -1422,11 +1421,13 @@
|
|||||||
"unavailable": "{agent} is unavailable on the current platform.",
|
"unavailable": "{agent} is unavailable on the current platform.",
|
||||||
"sdkMissing": "{agent} SDK is not installed"
|
"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}",
|
"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",
|
"connectFailedTitle": "{agent} connection failed",
|
||||||
"toolFallbackTitle": "Tool",
|
"toolFallbackTitle": "Tool",
|
||||||
"eventErrorTitle": "Agent Error",
|
"eventErrorTitle": "Agent Error",
|
||||||
|
|||||||
@@ -851,7 +851,6 @@
|
|||||||
"connection": {
|
"connection": {
|
||||||
"connected": "Conectado",
|
"connected": "Conectado",
|
||||||
"connecting": "Conectando...",
|
"connecting": "Conectando...",
|
||||||
"downloading": "Descargando...",
|
|
||||||
"prompting": "Respondiendo...",
|
"prompting": "Respondiendo...",
|
||||||
"error": "Error de conexión",
|
"error": "Error de conexión",
|
||||||
"disconnected": "Desconectado",
|
"disconnected": "Desconectado",
|
||||||
@@ -1422,11 +1421,13 @@
|
|||||||
"unavailable": "{agent} no está disponible en la plataforma actual.",
|
"unavailable": "{agent} no está disponible en la plataforma actual.",
|
||||||
"sdkMissing": "El SDK de {agent} no está instalado"
|
"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}",
|
"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}",
|
"connectFailedTitle": "Falló la conexión de {agent}",
|
||||||
"toolFallbackTitle": "Herramienta",
|
"toolFallbackTitle": "Herramienta",
|
||||||
"eventErrorTitle": "Error del agente",
|
"eventErrorTitle": "Error del agente",
|
||||||
|
|||||||
@@ -851,7 +851,6 @@
|
|||||||
"connection": {
|
"connection": {
|
||||||
"connected": "Connecté",
|
"connected": "Connecté",
|
||||||
"connecting": "Connexion...",
|
"connecting": "Connexion...",
|
||||||
"downloading": "Téléchargement...",
|
|
||||||
"prompting": "Réponse...",
|
"prompting": "Réponse...",
|
||||||
"error": "Erreur de connexion",
|
"error": "Erreur de connexion",
|
||||||
"disconnected": "Déconnecté",
|
"disconnected": "Déconnecté",
|
||||||
@@ -1422,11 +1421,13 @@
|
|||||||
"unavailable": "{agent} n'est pas disponible sur la plateforme actuelle.",
|
"unavailable": "{agent} n'est pas disponible sur la plateforme actuelle.",
|
||||||
"sdkMissing": "Le SDK de {agent} n'est pas installé"
|
"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}",
|
"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}",
|
"connectFailedTitle": "Échec de la connexion de {agent}",
|
||||||
"toolFallbackTitle": "Outil",
|
"toolFallbackTitle": "Outil",
|
||||||
"eventErrorTitle": "Erreur de l'agent",
|
"eventErrorTitle": "Erreur de l'agent",
|
||||||
|
|||||||
@@ -851,7 +851,6 @@
|
|||||||
"connection": {
|
"connection": {
|
||||||
"connected": "接続済み",
|
"connected": "接続済み",
|
||||||
"connecting": "接続中...",
|
"connecting": "接続中...",
|
||||||
"downloading": "ダウンロード中...",
|
|
||||||
"prompting": "応答中...",
|
"prompting": "応答中...",
|
||||||
"error": "接続エラー",
|
"error": "接続エラー",
|
||||||
"disconnected": "未接続",
|
"disconnected": "未接続",
|
||||||
@@ -1422,11 +1421,13 @@
|
|||||||
"unavailable": "{agent} は現在のプラットフォームでは利用できません。",
|
"unavailable": "{agent} は現在のプラットフォームでは利用できません。",
|
||||||
"sdkMissing": "{agent} SDK がインストールされていません"
|
"sdkMissing": "{agent} SDK がインストールされていません"
|
||||||
},
|
},
|
||||||
|
"backendErrors": {
|
||||||
|
"initializeTimeout": "{agent} の接続ハンドシェイクがタイムアウトしました(60 秒以内に応答なし)。設定を開いてエージェントとネットワーク設定を確認してください。",
|
||||||
|
"processExited": "{agent} プロセスが予期せず終了しました。",
|
||||||
|
"spawnFailed": "{agent} の起動に失敗しました: {message}",
|
||||||
|
"downloadFailed": "{agent} のダウンロードに失敗しました: {message}"
|
||||||
|
},
|
||||||
"unableReadAgentConfig": "エージェント設定を読み取れません: {message}",
|
"unableReadAgentConfig": "エージェント設定を読み取れません: {message}",
|
||||||
"autoLinkFailedTitle": "{agent} の自動リンクに失敗しました",
|
|
||||||
"preflightCheckFailedDefault": "事前チェックに失敗しました。エージェント設定を確認してください。",
|
|
||||||
"preflightFailedTitle": "{agent} の事前チェックに失敗しました",
|
|
||||||
"autoLinkPreflightFailed": "自動リンクの事前チェックに失敗しました: {message}",
|
|
||||||
"connectFailedTitle": "{agent} の接続に失敗しました",
|
"connectFailedTitle": "{agent} の接続に失敗しました",
|
||||||
"toolFallbackTitle": "ツール",
|
"toolFallbackTitle": "ツール",
|
||||||
"eventErrorTitle": "エージェントエラー",
|
"eventErrorTitle": "エージェントエラー",
|
||||||
|
|||||||
@@ -851,7 +851,6 @@
|
|||||||
"connection": {
|
"connection": {
|
||||||
"connected": "연결됨",
|
"connected": "연결됨",
|
||||||
"connecting": "연결 중...",
|
"connecting": "연결 중...",
|
||||||
"downloading": "다운로드 중...",
|
|
||||||
"prompting": "응답 중...",
|
"prompting": "응답 중...",
|
||||||
"error": "연결 오류",
|
"error": "연결 오류",
|
||||||
"disconnected": "연결 끊김",
|
"disconnected": "연결 끊김",
|
||||||
@@ -1422,11 +1421,13 @@
|
|||||||
"unavailable": "{agent}은(는) 현재 플랫폼에서 사용할 수 없습니다.",
|
"unavailable": "{agent}은(는) 현재 플랫폼에서 사용할 수 없습니다.",
|
||||||
"sdkMissing": "{agent} SDK가 설치되어 있지 않습니다"
|
"sdkMissing": "{agent} SDK가 설치되어 있지 않습니다"
|
||||||
},
|
},
|
||||||
|
"backendErrors": {
|
||||||
|
"initializeTimeout": "{agent} 연결 핸드셰이크가 시간 초과되었습니다(60초 동안 응답 없음). 설정을 열어 에이전트 및 네트워크 구성을 확인하세요.",
|
||||||
|
"processExited": "{agent} 프로세스가 예기치 않게 종료되었습니다.",
|
||||||
|
"spawnFailed": "{agent} 시작 실패: {message}",
|
||||||
|
"downloadFailed": "{agent} 다운로드 실패: {message}"
|
||||||
|
},
|
||||||
"unableReadAgentConfig": "에이전트 구성을 읽을 수 없습니다: {message}",
|
"unableReadAgentConfig": "에이전트 구성을 읽을 수 없습니다: {message}",
|
||||||
"autoLinkFailedTitle": "{agent} 자동 연결 실패",
|
|
||||||
"preflightCheckFailedDefault": "사전 점검에 실패했습니다. 에이전트 설정을 확인하세요.",
|
|
||||||
"preflightFailedTitle": "{agent} 사전 점검 실패",
|
|
||||||
"autoLinkPreflightFailed": "자동 연결 사전 점검 실패: {message}",
|
|
||||||
"connectFailedTitle": "{agent} 연결 실패",
|
"connectFailedTitle": "{agent} 연결 실패",
|
||||||
"toolFallbackTitle": "도구",
|
"toolFallbackTitle": "도구",
|
||||||
"eventErrorTitle": "에이전트 오류",
|
"eventErrorTitle": "에이전트 오류",
|
||||||
|
|||||||
@@ -851,7 +851,6 @@
|
|||||||
"connection": {
|
"connection": {
|
||||||
"connected": "Conectado",
|
"connected": "Conectado",
|
||||||
"connecting": "Conectando...",
|
"connecting": "Conectando...",
|
||||||
"downloading": "Baixando...",
|
|
||||||
"prompting": "Respondendo...",
|
"prompting": "Respondendo...",
|
||||||
"error": "Erro de conexão",
|
"error": "Erro de conexão",
|
||||||
"disconnected": "Desconectado",
|
"disconnected": "Desconectado",
|
||||||
@@ -1422,11 +1421,13 @@
|
|||||||
"unavailable": "{agent} está indisponível na plataforma atual.",
|
"unavailable": "{agent} está indisponível na plataforma atual.",
|
||||||
"sdkMissing": "O SDK de {agent} não está instalado"
|
"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}",
|
"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}",
|
"connectFailedTitle": "Falha na conexão de {agent}",
|
||||||
"toolFallbackTitle": "Ferramenta",
|
"toolFallbackTitle": "Ferramenta",
|
||||||
"eventErrorTitle": "Erro do agente",
|
"eventErrorTitle": "Erro do agente",
|
||||||
|
|||||||
@@ -851,7 +851,6 @@
|
|||||||
"connection": {
|
"connection": {
|
||||||
"connected": "已连接",
|
"connected": "已连接",
|
||||||
"connecting": "连接中...",
|
"connecting": "连接中...",
|
||||||
"downloading": "下载中...",
|
|
||||||
"prompting": "响应中...",
|
"prompting": "响应中...",
|
||||||
"error": "连接异常",
|
"error": "连接异常",
|
||||||
"disconnected": "未连接",
|
"disconnected": "未连接",
|
||||||
@@ -1422,11 +1421,13 @@
|
|||||||
"unavailable": "{agent} 当前平台不可用。",
|
"unavailable": "{agent} 当前平台不可用。",
|
||||||
"sdkMissing": "{agent} SDK 尚未安装"
|
"sdkMissing": "{agent} SDK 尚未安装"
|
||||||
},
|
},
|
||||||
|
"backendErrors": {
|
||||||
|
"initializeTimeout": "{agent} 连接握手超时(60 秒未响应),请前往设置页面检查智能体和网络配置。",
|
||||||
|
"processExited": "{agent} 进程意外退出。",
|
||||||
|
"spawnFailed": "启动 {agent} 失败:{message}",
|
||||||
|
"downloadFailed": "{agent} 下载失败:{message}"
|
||||||
|
},
|
||||||
"unableReadAgentConfig": "无法读取 Agent 配置:{message}",
|
"unableReadAgentConfig": "无法读取 Agent 配置:{message}",
|
||||||
"autoLinkFailedTitle": "{agent} 自动链接失败",
|
|
||||||
"preflightCheckFailedDefault": "预检查未通过,请检查 Agent 配置。",
|
|
||||||
"preflightFailedTitle": "{agent} 预检查失败",
|
|
||||||
"autoLinkPreflightFailed": "自动链接预检查失败:{message}",
|
|
||||||
"connectFailedTitle": "{agent} 连接失败",
|
"connectFailedTitle": "{agent} 连接失败",
|
||||||
"toolFallbackTitle": "工具",
|
"toolFallbackTitle": "工具",
|
||||||
"eventErrorTitle": "Agent 错误",
|
"eventErrorTitle": "Agent 错误",
|
||||||
|
|||||||
@@ -851,7 +851,6 @@
|
|||||||
"connection": {
|
"connection": {
|
||||||
"connected": "已連線",
|
"connected": "已連線",
|
||||||
"connecting": "連線中...",
|
"connecting": "連線中...",
|
||||||
"downloading": "下載中...",
|
|
||||||
"prompting": "回應中...",
|
"prompting": "回應中...",
|
||||||
"error": "連線異常",
|
"error": "連線異常",
|
||||||
"disconnected": "未連線",
|
"disconnected": "未連線",
|
||||||
@@ -1422,11 +1421,13 @@
|
|||||||
"unavailable": "{agent} 目前平台不可用。",
|
"unavailable": "{agent} 目前平台不可用。",
|
||||||
"sdkMissing": "{agent} SDK 尚未安裝"
|
"sdkMissing": "{agent} SDK 尚未安裝"
|
||||||
},
|
},
|
||||||
|
"backendErrors": {
|
||||||
|
"initializeTimeout": "{agent} 連線交握逾時(60 秒未回應),請前往設定頁面檢查智能體與網路設定。",
|
||||||
|
"processExited": "{agent} 處理程序意外結束。",
|
||||||
|
"spawnFailed": "啟動 {agent} 失敗:{message}",
|
||||||
|
"downloadFailed": "{agent} 下載失敗:{message}"
|
||||||
|
},
|
||||||
"unableReadAgentConfig": "無法讀取 Agent 設定:{message}",
|
"unableReadAgentConfig": "無法讀取 Agent 設定:{message}",
|
||||||
"autoLinkFailedTitle": "{agent} 自動連結失敗",
|
|
||||||
"preflightCheckFailedDefault": "預檢查未通過,請檢查 Agent 設定。",
|
|
||||||
"preflightFailedTitle": "{agent} 預檢查失敗",
|
|
||||||
"autoLinkPreflightFailed": "自動連結預檢查失敗:{message}",
|
|
||||||
"connectFailedTitle": "{agent} 連線失敗",
|
"connectFailedTitle": "{agent} 連線失敗",
|
||||||
"toolFallbackTitle": "工具",
|
"toolFallbackTitle": "工具",
|
||||||
"eventErrorTitle": "Agent 錯誤",
|
"eventErrorTitle": "Agent 錯誤",
|
||||||
|
|||||||
@@ -256,7 +256,6 @@ export const AGENT_COLORS: Record<AgentType, string> = {
|
|||||||
// ACP connection status (matches Rust ConnectionStatus)
|
// ACP connection status (matches Rust ConnectionStatus)
|
||||||
export type ConnectionStatus =
|
export type ConnectionStatus =
|
||||||
| "connecting"
|
| "connecting"
|
||||||
| "downloading"
|
|
||||||
| "connected"
|
| "connected"
|
||||||
| "prompting"
|
| "prompting"
|
||||||
| "disconnected"
|
| "disconnected"
|
||||||
@@ -442,7 +441,14 @@ export type AcpEvent =
|
|||||||
connection_id: string
|
connection_id: string
|
||||||
status: ConnectionStatus
|
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"
|
type: "available_commands"
|
||||||
connection_id: string
|
connection_id: string
|
||||||
|
|||||||
Reference in New Issue
Block a user