From 36b74ac88da315835f08dcead42656ef6d9039e4 Mon Sep 17 00:00:00 2001 From: wwellzero Date: Wed, 1 Jul 2026 07:48:56 +0800 Subject: [PATCH 1/3] fix(cron,acp,agent): restore cron tasks, ACP stale-session recovery, and OpenClaw/Remote new-conversation support on v0.1.39 --- crates/aionui-ai-agent/src/agent_task.rs | 40 +- crates/aionui-ai-agent/src/factory/aionrs.rs | 9 + crates/aionui-ai-agent/src/factory/mod.rs | 7 +- .../aionui-ai-agent/src/factory/openclaw.rs | 95 +++ crates/aionui-ai-agent/src/factory/remote.rs | 86 +++ .../aionui-ai-agent/src/manager/acp/agent.rs | 81 +++ .../src/manager/acp/agent_reconcile.rs | 7 +- .../src/manager/acp/agent_session_flow.rs | 20 +- .../src/manager/acp/error_mapping.rs | 1 + crates/aionui-ai-agent/src/manager/mod.rs | 2 + .../manager/openclaw/agent/confirmations.rs | 62 ++ .../src/manager/openclaw/agent/mod.rs | 525 ++++++++++++++ .../manager/openclaw/agent/spawn_helpers.rs | 115 ++++ .../src/manager/openclaw/config.rs | 242 +++++++ .../src/manager/openclaw/connection.rs | 550 +++++++++++++++ .../src/manager/openclaw/device_auth_store.rs | 175 +++++ .../src/manager/openclaw/device_identity.rs | 377 ++++++++++ .../src/manager/openclaw/event_mapper.rs | 643 ++++++++++++++++++ .../src/manager/openclaw/mod.rs | 9 + .../src/manager/openclaw/protocol.rs | 528 ++++++++++++++ .../src/manager/remote/agent.rs | 450 ++++++++++++ .../aionui-ai-agent/src/manager/remote/mod.rs | 3 + crates/aionui-ai-agent/src/protocol/error.rs | 16 + crates/aionui-ai-agent/src/session_context.rs | 3 +- .../tests/agent_types_integration.rs | 2 +- .../tests/factory_provider_integration.rs | 17 +- .../aionui-api-types/src/agent_build_extra.rs | 41 ++ crates/aionui-api-types/src/lib.rs | 4 +- crates/aionui-app/src/commands/server.rs | 89 +++ crates/aionui-app/src/router/state.rs | 1 + crates/aionui-app/src/services.rs | 14 +- crates/aionui-app/tests/conversation_e2e.rs | 4 +- crates/aionui-app/tests/shell_e2e.rs | 6 +- crates/aionui-channel/src/message_service.rs | 4 +- crates/aionui-common/src/enums.rs | 20 +- crates/aionui-conversation/src/service.rs | 59 +- .../aionui-conversation/src/service_test.rs | 24 +- .../src/session_context.rs | 107 ++- .../src/turn_orchestrator.rs | 4 +- .../tests/conversation_crud.rs | 13 +- crates/aionui-cron/src/executor.rs | 154 ++++- crates/aionui-cron/src/service.rs | 120 +++- .../aionui-cron/tests/service_integration.rs | 27 +- crates/aionui-shell/src/shell.rs | 4 +- .../aionui-shell/tests/shell_integration.rs | 5 +- 45 files changed, 4613 insertions(+), 152 deletions(-) create mode 100644 crates/aionui-ai-agent/src/factory/openclaw.rs create mode 100644 crates/aionui-ai-agent/src/factory/remote.rs create mode 100644 crates/aionui-ai-agent/src/manager/openclaw/agent/confirmations.rs create mode 100644 crates/aionui-ai-agent/src/manager/openclaw/agent/mod.rs create mode 100644 crates/aionui-ai-agent/src/manager/openclaw/agent/spawn_helpers.rs create mode 100644 crates/aionui-ai-agent/src/manager/openclaw/config.rs create mode 100644 crates/aionui-ai-agent/src/manager/openclaw/connection.rs create mode 100644 crates/aionui-ai-agent/src/manager/openclaw/device_auth_store.rs create mode 100644 crates/aionui-ai-agent/src/manager/openclaw/device_identity.rs create mode 100644 crates/aionui-ai-agent/src/manager/openclaw/event_mapper.rs create mode 100644 crates/aionui-ai-agent/src/manager/openclaw/mod.rs create mode 100644 crates/aionui-ai-agent/src/manager/openclaw/protocol.rs create mode 100644 crates/aionui-ai-agent/src/manager/remote/agent.rs create mode 100644 crates/aionui-ai-agent/src/manager/remote/mod.rs create mode 100644 crates/aionui-app/src/commands/server.rs diff --git a/crates/aionui-ai-agent/src/agent_task.rs b/crates/aionui-ai-agent/src/agent_task.rs index b059f0c40..3d29956e7 100644 --- a/crates/aionui-ai-agent/src/agent_task.rs +++ b/crates/aionui-ai-agent/src/agent_task.rs @@ -19,6 +19,7 @@ use tokio::sync::broadcast; use crate::error::AgentError; use crate::manager::acp::AcpAgentManager; use crate::manager::aionrs::AionrsAgentManager; +use crate::manager::openclaw::OpenClawAgentManager; use crate::protocol::events::AgentStreamEvent; use crate::protocol::send_error::AgentSendError; use crate::types::SendMessageData; @@ -150,6 +151,7 @@ pub trait IMockAgent: IAgentTask { pub enum AgentInstance { Acp(Arc), Aionrs(Arc), + OpenClaw(Arc), /// Test-only trait-object escape hatch used by downstream crates /// (conversation/cron/team/app tests) to inject fake agents without /// spinning up a real CLI or WebSocket connection. Gated behind @@ -169,6 +171,7 @@ impl AgentInstance { match self { Self::Acp(m) => m.as_ref(), Self::Aionrs(m) => m.as_ref(), + Self::OpenClaw(m) => m.as_ref(), #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.as_ref(), } @@ -234,6 +237,7 @@ impl AgentInstance { match self { Self::Acp(m) => m.kill_and_wait(reason), Self::Aionrs(m) => m.kill_and_wait(reason), + Self::OpenClaw(m) => m.kill_and_wait(reason), #[cfg(any(test, feature = "test-support"))] Self::Mock(_) => Box::pin(std::future::ready(())), } @@ -254,6 +258,7 @@ impl AgentInstance { match self { Self::Acp(m) => m.get_confirmations(), Self::Aionrs(m) => m.get_confirmations(), + Self::OpenClaw(_) => Vec::new(), #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.get_confirmations(), } @@ -270,6 +275,7 @@ impl AgentInstance { match self { Self::Acp(m) => m.confirm(msg_id, call_id, data, always_allow), Self::Aionrs(m) => m.confirm(msg_id, call_id, data, always_allow), + Self::OpenClaw(_) => Ok(()), #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.confirm(msg_id, call_id, data, always_allow), } @@ -280,6 +286,7 @@ impl AgentInstance { match self { Self::Acp(_) => false, Self::Aionrs(m) => m.check_approval(action, command_type), + Self::OpenClaw(_) => false, #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.check_approval(action, command_type), } @@ -288,7 +295,7 @@ impl AgentInstance { /// Session key for test doubles that expose one. pub fn get_session_key(&self) -> Option { match self { - Self::Acp(_) | Self::Aionrs(_) => None, + Self::Acp(_) | Self::Aionrs(_) | Self::OpenClaw(_) => None, #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.get_session_key(), } @@ -299,11 +306,29 @@ impl AgentInstance { match self { Self::Acp(m) => m.mode().await, Self::Aionrs(m) => m.mode().await, + Self::OpenClaw(_) => Ok(aionui_api_types::AgentModeResponse { + mode: String::new(), + initialized: false, + }), #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.mode().await, } } + /// Set the session mode. Unsupported for variants other than ACP / + /// Aionrs — returns a `BadRequest` so the caller can surface an + /// actionable error rather than silently no-op. + pub async fn set_mode(&self, _mode: &str) -> Result<(), AgentError> { + match self { + Self::Acp(m) => m.set_mode(_mode).await, + Self::Aionrs(m) => m.set_mode(_mode).await, + Self::OpenClaw(_) => Err(AgentError::bad_request( + "Mode switching is not supported for OpenClaw agents", + )), + #[cfg(any(test, feature = "test-support"))] + Self::Mock(m) => m.set_mode(_mode).await, + } + } /// Get the current session model info. Only ACP exposes a model /// catalog; other variants report `model_info = None` so the UI can /// hide the model picker without an error. @@ -320,7 +345,7 @@ impl AgentInstance { let model_info = merge_model_info(sdk_info, cc_switch_info); Ok(GetModelInfoResponse { model_info }) } - Self::Aionrs(_) => Ok(GetModelInfoResponse { model_info: None }), + Self::Aionrs(_) | Self::OpenClaw(_) => Ok(GetModelInfoResponse { model_info: None }), #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.get_model().await, } @@ -330,6 +355,9 @@ impl AgentInstance { match self { Self::Acp(m) => m.config_options().await, Self::Aionrs(m) => m.config_options().await, + Self::OpenClaw(_) => Ok(GetConfigOptionsResponse { + config_options: Vec::new(), + }), #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.get_config_options().await, } @@ -345,6 +373,9 @@ impl AgentInstance { match self { Self::Acp(m) => m.set_config_option_confirmed(option_id, value).await, Self::Aionrs(m) => m.set_config_option(option_id, value).await, + Self::OpenClaw(_) => Err(AgentError::bad_request( + "Config option switching is not supported for OpenClaw agents", + )), #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.set_config_option(option_id, value).await, } @@ -367,7 +398,7 @@ impl AgentInstance { aionui_common::normalize_keys_to_snake_case(&mut value); Ok(Some(value)) } - Self::Aionrs(_) => Ok(None), + Self::Aionrs(_) | Self::OpenClaw(_) => Ok(None), #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.get_usage().await, } @@ -380,6 +411,7 @@ impl AgentInstance { match self { Self::Acp(m) => m.load_slash_commands().await, Self::Aionrs(m) => m.get_slash_commands().await, + Self::OpenClaw(_) => Ok(Vec::new()), #[cfg(any(test, feature = "test-support"))] Self::Mock(m) => m.get_slash_commands().await, } @@ -406,7 +438,7 @@ impl AgentInstance { answer: Some("Side question support will be fully wired in app integration phase.".into()), }) } - Self::Aionrs(_) => Ok(SideQuestionResponse { + Self::Aionrs(_) | Self::OpenClaw(_) => Ok(SideQuestionResponse { status: "unsupported".into(), answer: None, }), diff --git a/crates/aionui-ai-agent/src/factory/aionrs.rs b/crates/aionui-ai-agent/src/factory/aionrs.rs index 041e67a35..48a62e76d 100644 --- a/crates/aionui-ai-agent/src/factory/aionrs.rs +++ b/crates/aionui-ai-agent/src/factory/aionrs.rs @@ -256,6 +256,15 @@ pub(crate) fn resolve_aionrs_url_and_compat( return (Some(base), compat); } + // Zhipu (bigmodel.cn) uses /v4/chat/completions instead of /v1/chat/completions. + // The base_url already includes the version (e.g. .../paas/v4), so we only + // append /chat/completions. + if raw_base_url.to_lowercase().contains("bigmodel.cn") { + let trimmed = raw_base_url.trim_end_matches('/'); + compat.api_path = Some("/chat/completions".to_owned()); + return (Some(trimmed.to_owned()), compat); + } + let normalized = normalize_aionrs_base_url(raw_base_url); let base_url = Some(normalized).filter(|u| !u.is_empty()); diff --git a/crates/aionui-ai-agent/src/factory/mod.rs b/crates/aionui-ai-agent/src/factory/mod.rs index b95cd3d6d..4e2b5966b 100644 --- a/crates/aionui-ai-agent/src/factory/mod.rs +++ b/crates/aionui-ai-agent/src/factory/mod.rs @@ -3,11 +3,12 @@ pub mod acp_assembler; mod acp; pub(crate) mod aionrs; mod context; +mod openclaw; use std::path::PathBuf; use std::sync::Arc; -use aionui_db::{IMcpServerRepository, IProviderRepository}; +use aionui_db::{IMcpServerRepository, IProviderRepository, IRemoteAgentRepository}; use aionui_realtime::EventBroadcaster; use futures_util::FutureExt; @@ -39,6 +40,9 @@ pub struct AgentFactoryDeps { /// inject enabled servers into `session/new` (ELECTRON-1JG fix). /// `None` for tests/composition paths that do not need MCP injection. pub mcp_server_repo: Option>, + /// Remote agent configuration repository. Required by the remote agent + /// factory to resolve `remote_agent_id` into gateway connection details. + pub remote_agent_repo: Arc, } /// Build a production agent factory that dispatches to concrete agent types. @@ -64,6 +68,7 @@ async fn build_agent(deps: Arc, options: BuildTaskOptions) -> match context.kind { AgentSessionKind::Acp(acp_context) => acp::build(deps, *acp_context, ctx).await, AgentSessionKind::Aionrs(aionrs_context) => aionrs::build(deps, *aionrs_context, model, ctx).await, + AgentSessionKind::OpenclawGateway(openclaw_context) => openclaw::build(deps, *openclaw_context, ctx).await, } } diff --git a/crates/aionui-ai-agent/src/factory/openclaw.rs b/crates/aionui-ai-agent/src/factory/openclaw.rs new file mode 100644 index 000000000..032e6d716 --- /dev/null +++ b/crates/aionui-ai-agent/src/factory/openclaw.rs @@ -0,0 +1,95 @@ +use std::sync::Arc; + +use crate::AgentError; +use aionui_api_types::OpenClawBuildExtra; +use aionui_common::AgentType; +use tracing::warn; + +use crate::agent_task::AgentInstance; +use crate::factory::AgentFactoryDeps; +use crate::factory::context::FactoryContext; +use crate::manager::openclaw::OpenClawAgentManager; + +pub(super) async fn build( + deps: Arc, + mut config: OpenClawBuildExtra, + ctx: FactoryContext, +) -> Result { + // If this is a remote-agent row, resolve the gateway details from the DB. + if let Some(remote_agent_id) = config.remote_agent_id.as_deref().filter(|id| !id.is_empty()) { + let row = deps + .remote_agent_repo + .find_by_id(remote_agent_id) + .await + .map_err(|e| AgentError::Internal(format!("Failed to load remote agent config: {e}")))? + .ok_or_else(|| AgentError::NotFound(format!("Remote agent '{remote_agent_id}' not found")))?; + let auth_token = row + .auth_token + .as_deref() + .filter(|t| !t.is_empty()) + .and_then(|encrypted| { + aionui_common::decrypt_string(encrypted, &deps.encryption_key) + .map_err(|e| { + warn!(error = %e, "Failed to decrypt remote agent auth_token"); + }) + .ok() + }); + + let (host, port) = parse_ws_url(&row.url).unwrap_or_else(|| { + warn!(url = %row.url, "Failed to parse remote agent URL, using as-is"); + (row.url.clone(), None) + }); + + config.backend = Some("remote".to_owned()); + config.agent_name = Some(row.name.clone()); + config.gateway.host = Some(host); + config.gateway.port = port; + config.gateway.token = auth_token; + config.gateway.password = None; + config.gateway.use_external_gateway = true; + config.gateway.cli_path = Some(row.url); + } + + // OpenClaw lives in the catalog as an internal row; reuse + // the registry-resolved path instead of re-running `which()`. + if config.gateway.cli_path.is_none() + && !config.gateway.use_external_gateway + && let Some(cli) = deps + .agent_registry + .list_by_agent_type(AgentType::OpenclawGateway) + .await + .into_iter() + .find_map(|m| m.resolved_command) + .map(|p| p.to_string_lossy().into_owned()) + { + config.gateway.cli_path = Some(cli); + } + + let resume_session_key = config.session_key.clone(); + let agent = OpenClawAgentManager::new( + ctx.conversation_id, + ctx.workspace, + config, + resume_session_key, + deps.data_dir.clone(), + ) + .await?; + let arc = Arc::new(agent); + arc.start_event_relay(); + Ok(AgentInstance::OpenClaw(arc)) +} + +/// Parse a WebSocket URL like `ws://127.0.0.1:18790` into (host, port). +fn parse_ws_url(url: &str) -> Option<(String, Option)> { + // Strip ws:// or wss:// prefix + let without_scheme = url.strip_prefix("ws://").or_else(|| url.strip_prefix("wss://"))?; + + // Split host:port + if let Some(colon_pos) = without_scheme.rfind(':') { + let host = without_scheme[..colon_pos].to_owned(); + let port = without_scheme[colon_pos + 1..].parse::().ok(); + Some((host, port)) + } else { + Some((without_scheme.to_owned(), None)) + } +} diff --git a/crates/aionui-ai-agent/src/factory/remote.rs b/crates/aionui-ai-agent/src/factory/remote.rs new file mode 100644 index 000000000..4365eacc8 --- /dev/null +++ b/crates/aionui-ai-agent/src/factory/remote.rs @@ -0,0 +1,86 @@ +use std::sync::Arc; + +use aionui_api_types::{OpenClawBuildExtra, OpenClawGatewayConfig, RemoteBuildExtra}; +use crate::AgentError; +use tracing::warn; + +use crate::agent_task::AgentInstance; +use crate::factory::AgentFactoryDeps; +use crate::factory::context::FactoryContext; +use crate::types::BuildTaskOptions; + +pub(super) async fn build( + deps: Arc, + options: BuildTaskOptions, + ctx: FactoryContext, +) -> Result { + let extra: RemoteBuildExtra = serde_json::from_value(options.extra.clone()) + .map_err(|e| AgentError::BadRequest(format!("Invalid Remote build options: {e}")))?; + let row = deps + .remote_agent_repo + .find_by_id(&extra.remote_agent_id) + .await + .map_err(|e| AgentError::Internal(format!("Failed to load remote agent config: {e}")))? + .ok_or_else(|| AgentError::NotFound(format!("Remote agent '{}' not found", extra.remote_agent_id)))?; + let auth_token = row + .auth_token + .as_deref() + .filter(|t| !t.is_empty()) + .and_then(|encrypted| { + aionui_common::decrypt_string(encrypted, &deps.encryption_key) + .map_err(|e| { + warn!(error = %e, "Failed to decrypt remote agent auth_token"); + }) + .ok() + }); + + // Delegate to OpenClaw gateway — RemoteAgentManager's WebSocket is broken. + // Build gateway config from the remote agent's URL and auth token. + let (host, port) = parse_ws_url(&row.url).unwrap_or_else(|| { + warn!(url = %row.url, "Failed to parse remote agent URL, using as-is"); + (row.url.clone(), None) + }); + + // Start from the original extra so fields like `skills`, `cron_job_id`, + // `session_key`, etc. set by the cron executor or frontend are preserved. + let mut openclaw_extra: OpenClawBuildExtra = + serde_json::from_value(options.extra.clone()).unwrap_or_else(|_| OpenClawBuildExtra { + backend: None, + agent_name: None, + gateway: OpenClawGatewayConfig::default(), + skills: Vec::new(), + preset_assistant_id: None, + cron_job_id: None, + session_key: None, + }); + + openclaw_extra.backend = Some("remote".to_owned()); + openclaw_extra.agent_name = Some(row.name.clone()); + openclaw_extra.gateway.host = Some(host); + openclaw_extra.gateway.port = port; + openclaw_extra.gateway.token = auth_token.clone(); + openclaw_extra.gateway.password = None; + openclaw_extra.gateway.use_external_gateway = true; + openclaw_extra.gateway.cli_path = Some(row.url.clone()); + + let mut openclaw_options = options; + openclaw_options.extra = serde_json::to_value(&openclaw_extra) + .map_err(|e| AgentError::Internal(format!("Failed to serialize OpenClaw build extra: {e}")))?; + + crate::factory::openclaw::build(deps, openclaw_options, ctx).await +} + +/// Parse a WebSocket URL like `ws://127.0.0.1:18790` into (host, port). +fn parse_ws_url(url: &str) -> Option<(String, Option)> { + // Strip ws:// or wss:// prefix + let without_scheme = url.strip_prefix("ws://").or_else(|| url.strip_prefix("wss://"))?; + + // Split host:port + if let Some(colon_pos) = without_scheme.rfind(':') { + let host = without_scheme[..colon_pos].to_owned(); + let port = without_scheme[colon_pos + 1..].parse::().ok(); + Some((host, port)) + } else { + Some((without_scheme.to_owned(), None)) + } +} diff --git a/crates/aionui-ai-agent/src/manager/acp/agent.rs b/crates/aionui-ai-agent/src/manager/acp/agent.rs index 83c7ccabb..0c3b1f0f7 100644 --- a/crates/aionui-ai-agent/src/manager/acp/agent.rs +++ b/crates/aionui-ai-agent/src/manager/acp/agent.rs @@ -548,6 +548,87 @@ impl AcpAgentManager { }) } + /// Set the mode for the current session. + pub(crate) async fn set_mode(&self, mode: &str) -> Result<(), AgentError> { + let normalized_mode = normalize_requested_mode(&self.params.metadata, mode); + if normalized_mode.is_empty() { + return Err(AgentError::bad_request("mode must not be empty")); + } + + let session_id = { + let session = self.session.read().await; + if !session.can_select_mode(&normalized_mode) { + warn!( + conversation_id = %self.params.conversation_id, + agent_backend = ?self.params.metadata.backend, + requested_mode_id = %normalized_mode, + "acp_set_mode_rejected_unavailable" + ); + return Err(AgentError::bad_request(format!( + "Mode '{normalized_mode}' is not available for this ACP session" + ))); + } + session.session_id().map(ToOwned::to_owned) + } + .ok_or_else(|| { + warn!( + conversation_id = %self.params.conversation_id, + agent_backend = ?self.params.metadata.backend, + requested_mode_id = %normalized_mode, + "acp_set_command_missing_session" + ); + AgentError::bad_request("No active session") + })?; + + info!( + conversation_id = %self.params.conversation_id, + agent_backend = ?self.params.metadata.backend, + requested_mode_id = %normalized_mode, + "acp_set_mode_requested" + ); + codex_sandbox::sync_for_agent(&self.params.metadata, Some(&normalized_mode)).await; + + if let Err(e) = self + .protocol + .set_mode(SetSessionModeRequest::new( + SessionId::new(session_id.clone()), + normalized_mode.clone(), + )) + .await + { + warn!( + conversation_id = %self.params.conversation_id, + agent_backend = ?self.params.metadata.backend, + requested_mode_id = %normalized_mode, + error = %e, + "acp_set_mode_failed" + ); + return Err(AgentError::from(e)); + } + + let mut session = self.session.write().await; + if session.session_id() != Some(session_id.as_str()) { + warn!( + conversation_id = %self.params.conversation_id, + agent_backend = ?self.params.metadata.backend, + requested_mode_id = %normalized_mode, + confirmed_session_id = %session_id, + active_session_id = ?session.session_id(), + "acp_set_mode_session_changed" + ); + return Err(AgentError::conflict("Active ACP session changed while applying mode")); + } + session.confirm_mode(ModeId::new(&normalized_mode)); + self.commit_session_changes(&mut session).await; + info!( + conversation_id = %self.params.conversation_id, + agent_backend = ?self.params.metadata.backend, + confirmed_mode_id = %normalized_mode, + "acp_set_mode_confirmed" + ); + Ok(()) + } + pub(crate) fn is_claude_backend(&self) -> bool { self.params.metadata.backend.as_deref() == Some("claude") } diff --git a/crates/aionui-ai-agent/src/manager/acp/agent_reconcile.rs b/crates/aionui-ai-agent/src/manager/acp/agent_reconcile.rs index a68644f1e..b04acfbea 100644 --- a/crates/aionui-ai-agent/src/manager/acp/agent_reconcile.rs +++ b/crates/aionui-ai-agent/src/manager/acp/agent_reconcile.rs @@ -1,6 +1,5 @@ use crate::manager::acp::AcpAgentManager; -use crate::manager::acp::error_mapping::is_acp_session_not_found; use crate::manager::acp::mode_normalize::normalize_requested_mode; use crate::manager::acp::session::PendingStartupConfigSeedResult; use crate::protocol::error::AcpError; @@ -79,7 +78,7 @@ impl AcpAgentManager { )) .await { - if is_acp_session_not_found(&e) { + if e.is_session_not_found_like(session_id) { warn!( conversation_id = %self.params.conversation_id, mode_id = %normalized, @@ -107,7 +106,7 @@ impl AcpAgentManager { )) .await { - if is_acp_session_not_found(&e) { + if e.is_session_not_found_like(session_id) { warn!( conversation_id = %self.params.conversation_id, model_id = %model, @@ -176,7 +175,7 @@ impl AcpAgentManager { actions = followup_actions.into(); } Err(err) => { - if is_acp_session_not_found(&err) { + if err.is_session_not_found_like(session_id) { warn!( conversation_id = %self.params.conversation_id, config_id = %key, diff --git a/crates/aionui-ai-agent/src/manager/acp/agent_session_flow.rs b/crates/aionui-ai-agent/src/manager/acp/agent_session_flow.rs index 44c7386fc..55d58d3e7 100644 --- a/crates/aionui-ai-agent/src/manager/acp/agent_session_flow.rs +++ b/crates/aionui-ai-agent/src/manager/acp/agent_session_flow.rs @@ -16,7 +16,9 @@ use tokio::sync::broadcast::error::TryRecvError; use super::agent::sdk_to_snake_value; use super::agent_close::STDERR_PEEK_LINES; -use super::error_mapping::{AcpSendFailure, is_acp_session_not_found}; +use super::error_mapping::AcpSendFailure; +#[cfg(test)] +use super::error_mapping::is_acp_session_not_found; use tracing::warn; #[derive(Debug)] @@ -125,7 +127,7 @@ impl AcpAgentManager { let req = self.params.new_session_request().meta(meta); let new_response = match self.protocol.new_session(req).await { Ok(r) => r, - Err(e) if is_acp_session_not_found(&e) => { + Err(e) if e.is_session_not_found_like(session_id) => { return self.rebuild_after_acp_session_not_found(session_id, e).await; } Err(e) => return Err(e.into()), @@ -157,7 +159,9 @@ impl AcpAgentManager { return match self.reconcile_session(&new_sid).await { Ok(()) => Ok(new_sid), - Err(e) if is_acp_session_not_found(&e) => self.rebuild_after_session_not_found(&new_sid, &e).await, + Err(e) if e.is_session_not_found_like(&new_sid) => { + self.rebuild_after_session_not_found(&new_sid, &e).await + } Err(e) => Err(e.into()), }; } @@ -177,7 +181,7 @@ impl AcpAgentManager { } let load_response = match self.protocol.load_session(load_req).await { Ok(r) => r, - Err(e) if is_acp_session_not_found(&e) => { + Err(e) if e.is_session_not_found_like(session_id) => { return self.rebuild_after_acp_session_not_found(session_id, e).await; } Err(e) => return Err(e.into()), @@ -204,7 +208,9 @@ impl AcpAgentManager { return match self.reconcile_session(session_id).await { Ok(()) => Ok(session_id.to_owned()), - Err(e) if is_acp_session_not_found(&e) => self.rebuild_after_session_not_found(session_id, &e).await, + Err(e) if e.is_session_not_found_like(session_id) => { + self.rebuild_after_session_not_found(session_id, &e).await + } Err(e) => Err(e.into()), }; } @@ -220,7 +226,9 @@ impl AcpAgentManager { self.emit_snapshot_events().await; match self.reconcile_session(session_id).await { Ok(()) => Ok(session_id.to_owned()), - Err(e) if is_acp_session_not_found(&e) => self.rebuild_after_session_not_found(session_id, &e).await, + Err(e) if e.is_session_not_found_like(session_id) => { + self.rebuild_after_session_not_found(session_id, &e).await + } Err(e) => Err(e.into()), } } diff --git a/crates/aionui-ai-agent/src/manager/acp/error_mapping.rs b/crates/aionui-ai-agent/src/manager/acp/error_mapping.rs index ab54db1d3..27df06feb 100644 --- a/crates/aionui-ai-agent/src/manager/acp/error_mapping.rs +++ b/crates/aionui-ai-agent/src/manager/acp/error_mapping.rs @@ -62,6 +62,7 @@ impl std::error::Error for AcpSendFailure { } } +#[cfg(test)] pub(super) fn is_acp_session_not_found(err: &AcpError) -> bool { matches!(err, AcpError::SessionNotFound { .. }) } diff --git a/crates/aionui-ai-agent/src/manager/mod.rs b/crates/aionui-ai-agent/src/manager/mod.rs index 328e94e2a..54386b957 100644 --- a/crates/aionui-ai-agent/src/manager/mod.rs +++ b/crates/aionui-ai-agent/src/manager/mod.rs @@ -1,3 +1,5 @@ pub mod acp; pub mod aionrs; +pub mod openclaw; pub(crate) mod process_registry; +pub mod remote; diff --git a/crates/aionui-ai-agent/src/manager/openclaw/agent/confirmations.rs b/crates/aionui-ai-agent/src/manager/openclaw/agent/confirmations.rs new file mode 100644 index 000000000..216bbc8e3 --- /dev/null +++ b/crates/aionui-ai-agent/src/manager/openclaw/agent/confirmations.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use crate::AgentError; +use aionui_common::Confirmation; +use serde_json::{Value, json}; +use tracing::warn; + +use crate::shared_kernel::approval_key; + +use super::OpenClawAgentManager; + +/// OpenClaw-specific operations reached through `AgentInstance::OpenClaw(..)` +/// matches in the routes + services (e.g. `persist_session_key` uses +/// `get_session_key`, and `get_openclaw_runtime` calls `get_diagnostics`). +impl OpenClawAgentManager { + pub fn confirm(&self, _msg_id: &str, call_id: &str, _data: Value, always_allow: bool) -> Result<(), AgentError> { + if let Ok(mut state) = self.state.try_write() { + if always_allow && let Some(conf) = state.confirmations.iter().find(|c| c.call_id == call_id) { + let key = approval_key(conf.action.as_deref(), conf.command_type.as_deref()); + state.approval_memory.insert(key, true); + } + state.confirmations.retain(|c| c.call_id != call_id); + } + + let connection = Arc::clone(&self.connection); + let call_id = call_id.to_owned(); + let option_id = if always_allow { "allow_always" } else { "allow_once" }; + let option_id = option_id.to_owned(); + tokio::spawn(async move { + let params = json!({ + "requestId": call_id, + "optionId": option_id, + }); + if let Err(e) = connection.request::("exec.approval.respond", params).await { + warn!(error = %e, "Failed to send OpenClaw approval response"); + } + }); + + Ok(()) + } + + pub fn get_confirmations(&self) -> Vec { + self.state + .try_read() + .map(|g| g.confirmations.clone()) + .unwrap_or_default() + } + + pub fn check_approval(&self, action: &str, command_type: Option<&str>) -> bool { + self.state + .try_read() + .map(|g| { + let key = approval_key(Some(action), command_type); + g.approval_memory.get(&key).copied().unwrap_or(false) + }) + .unwrap_or(false) + } + + pub fn get_session_key(&self) -> Option { + self.state.try_read().ok().and_then(|g| g.session_key.clone()) + } +} diff --git a/crates/aionui-ai-agent/src/manager/openclaw/agent/mod.rs b/crates/aionui-ai-agent/src/manager/openclaw/agent/mod.rs new file mode 100644 index 000000000..52334b32a --- /dev/null +++ b/crates/aionui-ai-agent/src/manager/openclaw/agent/mod.rs @@ -0,0 +1,525 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use crate::AgentError; +use aionui_common::{AgentKillReason, AgentType, Confirmation, ConversationStatus, ErrorChain, TimestampMs}; +use serde_json::{Value, json}; +use tokio::sync::{Mutex, RwLock, broadcast}; +use tracing::{debug, error, info, warn}; + +use crate::agent_runtime::AgentRuntime; +use crate::capability::cli_process::CliAgentProcess; +use crate::manager::process_registry::register_session_process; +use crate::protocol::events::AgentStreamEvent; +use crate::protocol::send_error::AgentSendError; +use crate::types::SendMessageData; +use aionui_api_types::OpenClawBuildExtra; + +use super::config::load_openclaw_config; +use super::connection::{AuthConfig, OpenClawConnection}; +use super::device_identity::load_or_create_identity; +use super::event_mapper::{TextFallbackState, map_openclaw_event}; +use super::protocol::{ + ChatAbortParams, ChatSendParams, SessionsResetParams, SessionsResetResponse, SessionsResolveParams, + SessionsResolveResponse, normalize_ws_url, +}; + +mod confirmations; +mod spawn_helpers; + +use spawn_helpers::{build_spawn_config, is_port_listening, wait_for_gateway_ready}; + +pub const DEFAULT_GATEWAY_PORT: u16 = 18789; + +const OPENCLAW_KILL_GRACE_MS: u64 = 1000; +pub(super) const GATEWAY_READY_TIMEOUT: Duration = Duration::from_secs(10); +pub(super) const GATEWAY_READY_POLL_INTERVAL: Duration = Duration::from_millis(200); +const STOP_FINISH_FALLBACK_TIMEOUT: Duration = Duration::from_secs(5); + +pub(super) struct OpenClawState { + pub(super) session_key: Option, + pub(super) confirmations: Vec, + pub(super) has_messages: bool, + pub(super) approval_memory: HashMap, +} + +pub struct OpenClawAgentManager { + runtime: AgentRuntime, + config: OpenClawBuildExtra, + gateway_process: Option>, + pub(super) connection: Arc, + pub(super) state: Arc>, + text_state: Mutex, +} + +impl OpenClawAgentManager { + pub async fn new( + conversation_id: String, + workspace: String, + config: OpenClawBuildExtra, + resume_session_key: Option, + data_dir: std::path::PathBuf, + ) -> Result { + let file_config = load_openclaw_config(); + + let host = config.gateway.host.as_deref().unwrap_or("127.0.0.1"); + let port = config + .gateway + .port + .or_else(|| { + file_config + .as_ref() + .and_then(|c| c.gateway.as_ref()) + .and_then(|g| g.port) + }) + .unwrap_or(DEFAULT_GATEWAY_PORT); + + let gateway_process = if !config.gateway.use_external_gateway { + let cli_path = config + .gateway + .cli_path + .as_deref() + .ok_or_else(|| AgentError::BadRequest("OpenClaw CLI path is required".into()))?; + + if !is_port_listening(host, port).await { + let spawn_config = build_spawn_config(cli_path, &workspace, &config.gateway); + let command_preview = spawn_config.command.display().to_string(); + let process = Arc::new(CliAgentProcess::spawn_for_sdk(spawn_config, &data_dir).await?); + register_session_process( + &data_dir, + Arc::clone(&process), + conversation_id.clone(), + AgentType::OpenclawGateway, + None, + Some(command_preview), + )?; + + wait_for_gateway_ready(host, port).await?; + + info!( + conversation_id = %conversation_id, + port = port, + "OpenClaw gateway subprocess ready" + ); + + Some(process) + } else { + debug!(port = port, "OpenClaw gateway already listening, skipping spawn"); + None + } + } else { + None + }; + + let ws_url = normalize_ws_url(host, port); + + let identity = load_or_create_identity(None)?; + + let token = config + .gateway + .token + .clone() + .or_else(|| super::config::get_gateway_auth_token(file_config.as_ref())) + .or_else(|| { + super::device_auth_store::load_device_auth_token(&identity.device_id, "operator").map(|e| e.token) + }); + let password = config + .gateway + .password + .clone() + .or_else(|| super::config::get_gateway_auth_password(file_config.as_ref())); + + let auth = if token.is_some() || password.is_some() { + Some(AuthConfig { token, password }) + } else { + None + }; + + let (connection, hello) = OpenClawConnection::connect(&ws_url, auth, &identity) + .await + .inspect_err(|e| { + error!( + conversation_id = %conversation_id, + url = %ws_url, + error = %ErrorChain(e), + "Failed to connect to OpenClaw gateway" + ); + })?; + + if let Some(ref auth_info) = hello.auth + && let Some(ref device_token) = auth_info.device_token + { + super::device_auth_store::store_device_auth_token( + &identity.device_id, + auth_info.role.as_deref().unwrap_or("operator"), + device_token, + auth_info.scopes.as_deref().unwrap_or(&[]), + ); + } + + info!( + conversation_id = %conversation_id, + url = %ws_url, + "Connected to OpenClaw gateway via WebSocket" + ); + + let has_resume_key = resume_session_key.is_some(); + if has_resume_key { + info!( + conversation_id = %conversation_id, + "Resuming OpenClaw session with stored session key" + ); + } + + let runtime = AgentRuntime::new(conversation_id, workspace, 256); + + let manager = Self { + runtime, + config, + gateway_process, + connection: Arc::clone(&connection), + state: Arc::new(RwLock::new(OpenClawState { + session_key: resume_session_key, + confirmations: Vec::new(), + has_messages: has_resume_key, + approval_memory: HashMap::new(), + })), + text_state: Mutex::new(TextFallbackState::new()), + }; + + Ok(manager) + } + + pub fn start_event_relay(self: &Arc) { + let this = Arc::clone(self); + tokio::spawn(async move { + this.run_event_relay().await; + }); + } + + async fn run_event_relay(self: Arc) { + let mut event_rx = self.connection.subscribe_events().await; + + loop { + match event_rx.recv().await { + Ok(event_frame) => { + self.runtime.bump_activity(); + + let session_key = self.state.read().await.session_key.clone(); + + let stream_events = { + let mut text_state = self.text_state.lock().await; + map_openclaw_event(&event_frame, &mut text_state, session_key.as_deref()) + }; + + for stream_event in stream_events { + self.update_state_from_event(&stream_event).await; + self.runtime.emit(stream_event); + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!( + conversation_id = %self.runtime.conversation_id(), + lagged = n, + "OpenClaw event relay lagged" + ); + } + Err(broadcast::error::RecvError::Closed) => { + debug!( + conversation_id = %self.runtime.conversation_id(), + "OpenClaw event channel closed" + ); + break; + } + } + } + + // Channel closed without a terminal event; finalize the turn if still running. + if self.runtime.status() == Some(ConversationStatus::Running) { + warn!( + conversation_id = %self.runtime.conversation_id(), + "OpenClaw event channel closed mid-turn; emitting fallback Finish" + ); + self.runtime.emit_finish(None); + } + } + + async fn update_state_from_event(&self, event: &AgentStreamEvent) { + match event { + AgentStreamEvent::Start(data) => { + self.runtime.transition_to(ConversationStatus::Running); + if let Some(ref sid) = data.session_id { + let mut state = self.state.write().await; + state.session_key = Some(sid.clone()); + } + } + AgentStreamEvent::Finish(data) => { + self.runtime.transition_to(ConversationStatus::Finished); + if let Some(ref sid) = data.session_id { + let mut state = self.state.write().await; + state.session_key = Some(sid.clone()); + } + } + AgentStreamEvent::Error(_) => { + self.runtime.transition_to(ConversationStatus::Finished); + } + AgentStreamEvent::AcpPermission(data) => { + if let Some(conf) = data.as_confirmation() { + let mut guard = self.state.write().await; + if let Some(existing) = guard.confirmations.iter_mut().find(|c| c.call_id == conf.call_id) { + *existing = conf; + } else { + guard.confirmations.push(conf); + } + } + } + _ => {} + } + } + + async fn do_send_message(&self, is_first: bool, data: SendMessageData) -> Result<(), AgentError> { + if is_first { + self.resolve_session().await?; + } + + let session_key = self + .state + .read() + .await + .session_key + .clone() + .ok_or_else(|| AgentError::Internal("No active session key".into()))?; + + let params = ChatSendParams { + session_key, + message: data.content, + idempotency_key: uuid::Uuid::new_v4().to_string(), + attachments: if data.files.is_empty() { + None + } else { + Some(data.files.into_iter().map(|f| json!(f)).collect()) + }, + }; + + self.connection + .request::("chat.send", serde_json::to_value(params).unwrap_or_default()) + .await?; + + Ok(()) + } + + /// Resolve gateway session: try to resume an existing session first, + /// then fall back to creating a new one via sessions.reset. + async fn resolve_session(&self) -> Result<(), AgentError> { + let resume_key = self.state.read().await.session_key.clone(); + + if let Some(ref key) = resume_key { + match self + .connection + .request::( + "sessions.resolve", + serde_json::to_value(SessionsResolveParams { key: key.clone() }).unwrap_or_default(), + ) + .await + { + Ok(resp) => { + let mut state = self.state.write().await; + state.session_key = Some(resp.key.clone()); + info!( + conversation_id = %self.runtime.conversation_id(), + session_key = %resp.key, + "Resumed OpenClaw session via sessions.resolve" + ); + return Ok(()); + } + Err(e) => { + warn!( + conversation_id = %self.runtime.conversation_id(), + error = %ErrorChain(&e), + "Failed to resume OpenClaw session, falling back to sessions.reset" + ); + } + } + } + + let resp: SessionsResetResponse = self + .connection + .request( + "sessions.reset", + serde_json::to_value(SessionsResetParams { + key: self.runtime.conversation_id().to_owned(), + reason: "new".into(), + }) + .unwrap_or_default(), + ) + .await?; + + if let Some(ref key) = resp.key { + let mut state = self.state.write().await; + state.session_key = Some(key.clone()); + } + + Ok(()) + } + + pub async fn get_diagnostics(&self) -> Value { + let state = self.state.read().await; + let host = self.config.gateway.host.as_deref().unwrap_or("127.0.0.1"); + let port = self.config.gateway.port.unwrap_or(DEFAULT_GATEWAY_PORT); + + json!({ + "workspace": self.runtime.workspace(), + "backend": serde_json::to_value(&self.config.backend).unwrap_or_default(), + "agentName": self.config.agent_name, + "cliPath": self.config.gateway.cli_path, + "gatewayHost": host, + "gatewayPort": port, + "conversationId": self.runtime.conversation_id(), + "isConnected": self.connection.is_connected(), + "hasActiveSession": state.session_key.is_some(), + "sessionKey": state.session_key, + }) + } +} + +#[async_trait::async_trait] +impl crate::agent_task::IAgentTask for OpenClawAgentManager { + fn agent_type(&self) -> AgentType { + AgentType::OpenclawGateway + } + + fn conversation_id(&self) -> &str { + self.runtime.conversation_id() + } + + fn workspace(&self) -> &str { + self.runtime.workspace() + } + + fn status(&self) -> Option { + self.runtime.status() + } + + fn last_activity_at(&self) -> TimestampMs { + self.runtime.last_activity_at() + } + + fn subscribe(&self) -> broadcast::Receiver { + self.runtime.subscribe() + } + + async fn send_message(&self, data: SendMessageData) -> Result<(), AgentSendError> { + self.runtime.bump_activity(); + + let is_first = { + let mut state = self.state.write().await; + let first = !state.has_messages; + state.has_messages = true; + first + }; + self.runtime.transition_to(ConversationStatus::Running); + + { + let mut text_state = self.text_state.lock().await; + text_state.reset_for_new_turn(); + } + + match self.do_send_message(is_first, data).await { + Ok(()) => Ok(()), + Err(err) => { + error!( + conversation_id = %self.runtime.conversation_id(), + error = %ErrorChain(&err), + "OpenClaw send_message failed, emitting Error+Finish" + ); + let send_error = AgentSendError::from_agent_error(err); + self.runtime.emit_error_data(send_error.stream_error().clone()); + self.runtime.emit_finish(None); + Err(send_error) + } + } + } + + async fn cancel(&self) -> Result<(), AgentError> { + let session_key = self.state.read().await.session_key.clone(); + if let Some(ref key) = session_key { + let params = ChatAbortParams { + session_key: key.clone(), + run_id: None, + }; + let _ = self + .connection + .request::("chat.abort", serde_json::to_value(params).unwrap_or_default()) + .await; + } + + { + let mut state = self.state.write().await; + state.confirmations.clear(); + } + + let runtime = self.runtime.clone(); + let conversation_id = self.runtime.conversation_id().to_owned(); + tokio::spawn(async move { + tokio::time::sleep(STOP_FINISH_FALLBACK_TIMEOUT).await; + let needs_fallback = runtime + .status() + .map(|s| s == ConversationStatus::Running) + .unwrap_or(false); + if needs_fallback { + warn!( + conversation_id = %conversation_id, + "Gateway did not send abort event within timeout, emitting fallback Finish" + ); + runtime.emit_error("Stopped by user"); + runtime.emit_finish(None); + } + }); + + Ok(()) + } + + fn kill(&self, reason: Option) -> Result<(), AgentError> { + info!( + conversation_id = %self.runtime.conversation_id(), + ?reason, + "Killing OpenClaw agent" + ); + + let connection = Arc::clone(&self.connection); + tokio::spawn(async move { + connection.close().await; + }); + + if let Some(ref process) = self.gateway_process { + let process = Arc::clone(process); + let grace = Duration::from_millis(OPENCLAW_KILL_GRACE_MS); + tokio::spawn(async move { + if let Err(e) = process.kill(grace).await { + error!(error = %ErrorChain(&e), "Failed to kill OpenClaw gateway process"); + } + }); + } + + Ok(()) + } +} + +impl OpenClawAgentManager { + pub fn kill_and_wait( + &self, + reason: Option, + ) -> std::pin::Pin + Send>> { + let _ = crate::agent_task::IAgentTask::kill(self, reason); + if let Some(ref process) = self.gateway_process { + let process = Arc::clone(process); + let grace = Duration::from_millis(OPENCLAW_KILL_GRACE_MS); + Box::pin(async move { + let _ = process.kill(grace).await; + }) + } else { + Box::pin(std::future::ready(())) + } + } +} diff --git a/crates/aionui-ai-agent/src/manager/openclaw/agent/spawn_helpers.rs b/crates/aionui-ai-agent/src/manager/openclaw/agent/spawn_helpers.rs new file mode 100644 index 000000000..c6e1209a6 --- /dev/null +++ b/crates/aionui-ai-agent/src/manager/openclaw/agent/spawn_helpers.rs @@ -0,0 +1,115 @@ +use crate::AgentError; +use aionui_api_types::OpenClawGatewayConfig; +use aionui_common::{CommandSpec, EnvVar}; + +use super::{DEFAULT_GATEWAY_PORT, GATEWAY_READY_POLL_INTERVAL, GATEWAY_READY_TIMEOUT}; + +pub(super) fn build_spawn_config(cli_path: &str, workspace: &str, gateway: &OpenClawGatewayConfig) -> CommandSpec { + let host = gateway.host.as_deref().unwrap_or("127.0.0.1"); + let port = gateway.port.unwrap_or(DEFAULT_GATEWAY_PORT); + + let mut env = vec![ + EnvVar { + name: "OPENCLAW_GATEWAY_HOST".into(), + value: host.to_owned(), + }, + EnvVar { + name: "OPENCLAW_GATEWAY_PORT".into(), + value: port.to_string(), + }, + ]; + + if let Some(ref token) = gateway.token { + env.push(EnvVar { + name: "OPENCLAW_GATEWAY_TOKEN".into(), + value: token.clone(), + }); + } + if let Some(ref password) = gateway.password { + env.push(EnvVar { + name: "OPENCLAW_GATEWAY_PASSWORD".into(), + value: password.clone(), + }); + } + + CommandSpec { + command: cli_path.into(), + args: vec!["gateway".into(), "--port".into(), port.to_string()], + env, + cwd: Some(workspace.to_owned()), + } +} + +pub(super) async fn is_port_listening(host: &str, port: u16) -> bool { + tokio::net::TcpStream::connect((host, port)).await.is_ok() +} + +pub(super) async fn wait_for_gateway_ready(host: &str, port: u16) -> Result<(), AgentError> { + let start = tokio::time::Instant::now(); + while start.elapsed() < GATEWAY_READY_TIMEOUT { + if is_port_listening(host, port).await { + return Ok(()); + } + tokio::time::sleep(GATEWAY_READY_POLL_INTERVAL).await; + } + Err(AgentError::Internal(format!( + "OpenClaw gateway did not become ready on {host}:{port} within {}s", + GATEWAY_READY_TIMEOUT.as_secs() + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shared_kernel::approval_key; + + #[test] + fn default_gateway_port_is_18789() { + assert_eq!(DEFAULT_GATEWAY_PORT, 18789); + } + + fn env_val<'a>(config: &'a CommandSpec, name: &str) -> Option<&'a str> { + config.env.iter().find(|e| e.name == name).map(|e| e.value.as_str()) + } + + #[test] + fn build_spawn_config_with_defaults() { + let gateway = OpenClawGatewayConfig { + host: None, + port: None, + token: None, + password: None, + use_external_gateway: false, + cli_path: Some("/usr/bin/openclaw".into()), + }; + let config = build_spawn_config("/usr/bin/openclaw", "/proj", &gateway); + assert_eq!(config.command.to_str().unwrap(), "/usr/bin/openclaw"); + assert_eq!(env_val(&config, "OPENCLAW_GATEWAY_HOST").unwrap(), "127.0.0.1"); + assert_eq!(env_val(&config, "OPENCLAW_GATEWAY_PORT").unwrap(), "18789"); + assert!(env_val(&config, "OPENCLAW_GATEWAY_TOKEN").is_none()); + } + + #[test] + fn build_spawn_config_with_custom_gateway() { + let gateway = OpenClawGatewayConfig { + host: Some("remote.host".into()), + port: Some(9999), + token: Some("secret".into()), + password: Some("pass".into()), + use_external_gateway: true, + cli_path: Some("/usr/bin/openclaw".into()), + }; + let config = build_spawn_config("/usr/bin/openclaw", "/proj", &gateway); + assert_eq!(env_val(&config, "OPENCLAW_GATEWAY_HOST").unwrap(), "remote.host"); + assert_eq!(env_val(&config, "OPENCLAW_GATEWAY_PORT").unwrap(), "9999"); + assert_eq!(env_val(&config, "OPENCLAW_GATEWAY_TOKEN").unwrap(), "secret"); + assert_eq!(env_val(&config, "OPENCLAW_GATEWAY_PASSWORD").unwrap(), "pass"); + } + + #[test] + fn approval_key_formats_correctly() { + assert_eq!(approval_key(Some("edit"), Some("file")), "edit:file"); + assert_eq!(approval_key(Some("edit"), None), "edit"); + assert_eq!(approval_key(None, None), ""); + } +} diff --git a/crates/aionui-ai-agent/src/manager/openclaw/config.rs b/crates/aionui-ai-agent/src/manager/openclaw/config.rs new file mode 100644 index 000000000..c64bd7ff8 --- /dev/null +++ b/crates/aionui-ai-agent/src/manager/openclaw/config.rs @@ -0,0 +1,242 @@ +use std::fs; +use std::path::PathBuf; + +use serde::Deserialize; +use tracing::debug; + +use super::agent::DEFAULT_GATEWAY_PORT; + +#[derive(Debug, Deserialize, Default)] +pub struct OpenClawFileConfig { + pub gateway: Option, +} + +#[derive(Debug, Deserialize, Default)] +pub struct GatewayFileConfig { + pub port: Option, + pub auth: Option, +} + +#[derive(Debug, Deserialize, Default)] +pub struct AuthFileConfig { + pub mode: Option, + pub token: Option, + pub password: Option, +} + +const STATE_DIR_NAMES: &[&str] = &[".openclaw", ".clawdbot", ".moltbot", ".moldbot"]; +const CONFIG_FILE_NAMES: &[&str] = &["openclaw.json", "clawdbot.json", "moltbot.json", "moldbot.json"]; + +fn resolve_state_dir() -> Option { + if let Ok(dir) = std::env::var("OPENCLAW_STATE_DIR") { + let p = PathBuf::from(dir); + if p.is_dir() { + return Some(p); + } + } + if let Ok(dir) = std::env::var("CLAWDBOT_STATE_DIR") { + let p = PathBuf::from(dir); + if p.is_dir() { + return Some(p); + } + } + + let home = dirs::home_dir()?; + for name in STATE_DIR_NAMES { + let p = home.join(name); + if p.is_dir() { + return Some(p); + } + } + None +} + +fn strip_jsonc_comments(input: &str) -> String { + let mut result = String::with_capacity(input.len()); + let mut in_string = false; + let mut escape_next = false; + let chars: Vec = input.chars().collect(); + let len = chars.len(); + let mut i = 0; + + while i < len { + if escape_next { + result.push(chars[i]); + escape_next = false; + i += 1; + continue; + } + + if in_string { + if chars[i] == '\\' { + escape_next = true; + result.push(chars[i]); + } else if chars[i] == '"' { + in_string = false; + result.push(chars[i]); + } else { + result.push(chars[i]); + } + i += 1; + continue; + } + + if chars[i] == '"' { + in_string = true; + result.push(chars[i]); + i += 1; + continue; + } + + if i + 1 < len && chars[i] == '/' && chars[i + 1] == '/' { + while i < len && chars[i] != '\n' { + i += 1; + } + continue; + } + + if i + 1 < len && chars[i] == '/' && chars[i + 1] == '*' { + i += 2; + while i + 1 < len && !(chars[i] == '*' && chars[i + 1] == '/') { + i += 1; + } + if i + 1 < len { + i += 2; + } + continue; + } + + result.push(chars[i]); + i += 1; + } + result +} + +pub fn load_openclaw_config() -> Option { + if let Ok(path) = std::env::var("OPENCLAW_CONFIG_PATH") { + let p = PathBuf::from(path); + if p.is_file() { + return read_config_file(&p); + } + } + + let state_dir = resolve_state_dir()?; + for name in CONFIG_FILE_NAMES { + let p = state_dir.join(name); + if p.is_file() + && let Some(config) = read_config_file(&p) + { + debug!(path = %p.display(), "Loaded OpenClaw config"); + return Some(config); + } + } + None +} + +fn read_config_file(path: &PathBuf) -> Option { + let content = fs::read_to_string(path).ok()?; + let clean = strip_jsonc_comments(&content); + serde_json::from_str(&clean).ok() +} + +pub fn get_gateway_port(config: Option<&OpenClawFileConfig>) -> u16 { + config + .and_then(|c| c.gateway.as_ref()) + .and_then(|g| g.port) + .unwrap_or(DEFAULT_GATEWAY_PORT) +} + +pub fn get_gateway_auth_token(config: Option<&OpenClawFileConfig>) -> Option { + config + .and_then(|c| c.gateway.as_ref()) + .and_then(|g| g.auth.as_ref()) + .and_then(|a| a.token.clone()) +} + +pub fn get_gateway_auth_password(config: Option<&OpenClawFileConfig>) -> Option { + config + .and_then(|c| c.gateway.as_ref()) + .and_then(|g| g.auth.as_ref()) + .and_then(|a| a.password.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_jsonc_line_comments() { + let input = r#"{ + "gateway": { + "port": 18789 // default port + } +}"#; + let clean = strip_jsonc_comments(input); + let config: OpenClawFileConfig = serde_json::from_str(&clean).unwrap(); + assert_eq!(config.gateway.as_ref().and_then(|g| g.port), Some(18789)); + } + + #[test] + fn strip_jsonc_block_comments() { + let input = r#"{ + "gateway": { + /* authentication settings */ + "auth": { + "mode": "token", + "token": "secret" + } + } +}"#; + let clean = strip_jsonc_comments(input); + let config: OpenClawFileConfig = serde_json::from_str(&clean).unwrap(); + let token = config + .gateway + .as_ref() + .and_then(|g| g.auth.as_ref()) + .and_then(|a| a.token.as_deref()); + assert_eq!(token, Some("secret")); + } + + #[test] + fn comments_inside_strings_preserved() { + let input = r#"{"gateway":{"auth":{"token":"my//token/*value*/"}}}"#; + let clean = strip_jsonc_comments(input); + let config: OpenClawFileConfig = serde_json::from_str(&clean).unwrap(); + let token = config + .gateway + .as_ref() + .and_then(|g| g.auth.as_ref()) + .and_then(|a| a.token.as_deref()); + assert_eq!(token, Some("my//token/*value*/")); + } + + #[test] + fn parse_full_config() { + let json = r#"{ + "gateway": { + "port": 9999, + "auth": { + "mode": "password", + "password": "my-pass" + } + } +}"#; + let config: OpenClawFileConfig = serde_json::from_str(json).unwrap(); + assert_eq!(get_gateway_port(Some(&config)), 9999); + assert_eq!(get_gateway_auth_password(Some(&config)).as_deref(), Some("my-pass")); + assert!(get_gateway_auth_token(Some(&config)).is_none()); + } + + #[test] + fn default_port_when_no_config() { + assert_eq!(get_gateway_port(None), DEFAULT_GATEWAY_PORT); + } + + #[test] + fn empty_config_returns_defaults() { + let config: OpenClawFileConfig = serde_json::from_str("{}").unwrap(); + assert_eq!(get_gateway_port(Some(&config)), DEFAULT_GATEWAY_PORT); + assert!(get_gateway_auth_token(Some(&config)).is_none()); + assert!(get_gateway_auth_password(Some(&config)).is_none()); + } +} diff --git a/crates/aionui-ai-agent/src/manager/openclaw/connection.rs b/crates/aionui-ai-agent/src/manager/openclaw/connection.rs new file mode 100644 index 000000000..64e10d1f5 --- /dev/null +++ b/crates/aionui-ai-agent/src/manager/openclaw/connection.rs @@ -0,0 +1,550 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; +use std::time::Duration; + +use crate::AgentError; +use futures_util::{SinkExt, StreamExt}; +use serde::de::DeserializeOwned; +use serde_json::Value; +use tokio::sync::{Mutex, broadcast, oneshot}; +use tokio_tungstenite::tungstenite::Message; +use tracing::{debug, error, warn}; + +use super::device_identity::{DeviceIdentity, build_device_auth_params}; +use super::protocol::{ + AuthParams, CLIENT_DISPLAY_NAME, CLIENT_ID, CLIENT_MODE, CLIENT_VERSION, ClientInfo, ConnectParams, EventFrame, + HelloOk, IncomingFrame, OPENCLAW_MAX_PROTOCOL_VERSION, OPENCLAW_MIN_PROTOCOL_VERSION, RequestFrame, +}; + +type WsSink = futures_util::stream::SplitSink< + tokio_tungstenite::WebSocketStream>, + Message, +>; + +type WsStream = futures_util::stream::SplitStream< + tokio_tungstenite::WebSocketStream>, +>; + +const EVENT_CHANNEL_CAPACITY: usize = 256; +const CHALLENGE_TIMEOUT: Duration = Duration::from_secs(5); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const DEFAULT_TICK_INTERVAL_MS: u64 = 30_000; + +type PendingSender = oneshot::Sender>; + +pub struct AuthConfig { + pub token: Option, + pub password: Option, +} + +pub struct OpenClawConnection { + ws_sink: Mutex>, + pending: Mutex>, + event_tx: Mutex>>, + connected: AtomicBool, + challenge_tx: Mutex>>>, + _reader_handle: Mutex>>, + last_tick: AtomicI64, + tick_interval_ms: AtomicU64, +} + +impl OpenClawConnection { + pub async fn connect( + url: &str, + auth: Option, + identity: &DeviceIdentity, + ) -> Result<(Arc, HelloOk), AgentError> { + let (ws_stream, _) = tokio_tungstenite::connect_async(url) + .await + .map_err(|e| AgentError::Internal(format!("OpenClaw WebSocket connection failed: {e}")))?; + + let (sink, stream) = ws_stream.split(); + let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); + let (challenge_tx, challenge_rx) = oneshot::channel(); + let now = aionui_common::now_ms(); + + let conn = Arc::new(Self { + ws_sink: Mutex::new(Some(sink)), + pending: Mutex::new(HashMap::new()), + event_tx: Mutex::new(Some(event_tx)), + connected: AtomicBool::new(false), + challenge_tx: Mutex::new(Some(challenge_tx)), + _reader_handle: Mutex::new(None), + last_tick: AtomicI64::new(now), + tick_interval_ms: AtomicU64::new(DEFAULT_TICK_INTERVAL_MS), + }); + + let reader_conn = Arc::clone(&conn); + let reader_handle = tokio::spawn(async move { + reader_conn.run_reader(stream).await; + }); + *conn._reader_handle.lock().await = Some(reader_handle); + + let nonce = match tokio::time::timeout(CHALLENGE_TIMEOUT, challenge_rx).await { + Ok(Ok(nonce)) => nonce, + _ => None, + }; + + let hello = conn.send_connect(nonce.as_deref(), auth, identity).await?; + conn.connected.store(true, Ordering::Relaxed); + + if let Some(ref policy) = hello.policy + && let Some(interval) = policy.tick_interval_ms + { + conn.tick_interval_ms.store(interval, Ordering::Relaxed); + } + + conn.start_tick_watchdog(); + + debug!( + protocol = ?hello.protocol, + server_version = ?hello.server.as_ref().and_then(|s| s.version.as_deref()), + "OpenClaw handshake complete" + ); + + Ok((conn, hello)) + } + + fn start_tick_watchdog(self: &Arc) { + let conn = Arc::clone(self); + tokio::spawn(async move { + loop { + let interval_ms = conn.tick_interval_ms.load(Ordering::Relaxed).max(1000); + tokio::time::sleep(Duration::from_millis(interval_ms)).await; + + if !conn.connected.load(Ordering::Relaxed) { + break; + } + + let last = conn.last_tick.load(Ordering::Relaxed); + let gap = aionui_common::now_ms() - last; + if gap > (interval_ms as i64) * 2 { + warn!( + gap_ms = gap, + interval_ms = interval_ms, + "OpenClaw tick timeout, closing connection" + ); + conn.close().await; + break; + } + } + }); + } + + async fn send_connect( + &self, + nonce: Option<&str>, + auth: Option, + identity: &DeviceIdentity, + ) -> Result { + let auth_params = match &auth { + Some(a) if a.token.is_some() || a.password.is_some() => Some(AuthParams { + token: a.token.clone(), + password: a.password.clone(), + }), + _ => None, + }; + + let device_params = build_device_auth_params(identity, nonce, auth.as_ref().and_then(|a| a.token.as_deref())); + + let params = ConnectParams { + min_protocol: OPENCLAW_MIN_PROTOCOL_VERSION, + max_protocol: OPENCLAW_MAX_PROTOCOL_VERSION, + client: ClientInfo { + id: CLIENT_ID, + display_name: CLIENT_DISPLAY_NAME, + version: CLIENT_VERSION, + platform: std::env::consts::OS, + mode: CLIENT_MODE, + }, + caps: vec!["tool-events"], + role: Some("operator".into()), + scopes: Some(vec!["operator.admin".into()]), + auth: auth_params, + device: Some(device_params), + }; + + self.request::("connect", serde_json::to_value(params).unwrap_or_default()) + .await + } + + pub async fn request(&self, method: &str, params: Value) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel(); + + { + let mut pending = self.pending.lock().await; + pending.insert(id.clone(), tx); + } + + let frame = RequestFrame { + type_: "req", + id: id.clone(), + method: method.into(), + params: Some(params), + }; + self.ws_send_frame(&frame).await?; + + let result = tokio::time::timeout(REQUEST_TIMEOUT, rx) + .await + .map_err(|_| AgentError::Internal(format!("OpenClaw request '{method}' timed out")))? + .map_err(|_| AgentError::Internal(format!("OpenClaw request '{method}' cancelled")))??; + + serde_json::from_value(result) + .map_err(|e| AgentError::Internal(format!("Failed to parse OpenClaw response for '{method}': {e}"))) + } + + pub async fn subscribe_events(&self) -> broadcast::Receiver { + if let Some(tx) = self.event_tx.lock().await.as_ref() { + tx.subscribe() + } else { + // Connection is closed; return a receiver that is already closed. + let (tx, rx) = broadcast::channel(1); + drop(tx); + rx + } + } + + pub fn is_connected(&self) -> bool { + self.connected.load(Ordering::Relaxed) + } + + pub async fn close(&self) { + self.connected.store(false, Ordering::Relaxed); + + // Drop the event sender so any waiting relay sees the channel close. + let _ = self.event_tx.lock().await.take(); + + if let Some(mut sink) = self.ws_sink.lock().await.take() { + let _ = sink.close().await; + } + + // Fail all pending requests + let mut pending = self.pending.lock().await; + for (_, tx) in pending.drain() { + let _ = tx.send(Err(AgentError::Internal("Connection closed".into()))); + } + } + + async fn run_reader(self: Arc, mut stream: WsStream) { + while let Some(msg) = stream.next().await { + match msg { + Ok(Message::Text(text)) => { + self.handle_incoming_text(&text).await; + } + Ok(Message::Close(_)) => { + debug!("OpenClaw WebSocket closed by server"); + break; + } + Err(e) => { + warn!(error = %e, "OpenClaw WebSocket read error"); + break; + } + _ => {} + } + } + + self.connected.store(false, Ordering::Relaxed); + + // Drop the event sender so waiting relays observe channel close. + let _ = self.event_tx.lock().await.take(); + + // Fail all pending requests + let mut pending = self.pending.lock().await; + for (_, tx) in pending.drain() { + let _ = tx.send(Err(AgentError::Internal("OpenClaw connection closed".into()))); + } + } + + async fn handle_incoming_text(&self, text: &str) { + let frame: IncomingFrame = match serde_json::from_str(text) { + Ok(f) => f, + Err(_) => { + debug!("Unrecognized OpenClaw message, skipping"); + return; + } + }; + + match frame { + IncomingFrame::Res(res) => { + let mut pending = self.pending.lock().await; + if let Some(tx) = pending.remove(&res.id) { + if res.ok { + let _ = tx.send(Ok(res.payload.unwrap_or(Value::Null))); + } else { + let msg = res + .error + .map(|e| format!("{}: {}", e.code, e.message)) + .unwrap_or_else(|| "Unknown error".into()); + let _ = tx.send(Err(AgentError::Internal(msg))); + } + } + } + IncomingFrame::Event(evt) => { + if evt.event == "connect.challenge" { + let nonce = evt + .payload + .as_ref() + .and_then(|p| p.get("nonce")) + .and_then(|v| v.as_str()) + .map(String::from); + if let Some(tx) = self.challenge_tx.lock().await.take() { + let _ = tx.send(nonce); + } + return; + } + + if evt.event == "tick" { + self.last_tick.store(aionui_common::now_ms(), Ordering::Relaxed); + return; + } + + if let Some(tx) = self.event_tx.lock().await.as_ref() { + let _ = tx.send(evt); + } + } + } + } + + async fn ws_send_frame(&self, frame: &RequestFrame) -> Result<(), AgentError> { + let text = serde_json::to_string(frame) + .map_err(|e| AgentError::Internal(format!("Failed to serialize request frame: {e}")))?; + + let mut guard = self.ws_sink.lock().await; + let sink = guard + .as_mut() + .ok_or_else(|| AgentError::Internal("OpenClaw WebSocket not connected".into()))?; + + sink.send(Message::Text(text.into())).await.map_err(|e| { + error!(error = %e, "Failed to send OpenClaw WebSocket message"); + AgentError::Internal(format!("OpenClaw WebSocket send failed: {e}")) + }) + } +} + +#[cfg(test)] +mod tests { + use super::super::device_identity::generate_identity; + use super::*; + use serde_json::json; + use tokio::net::TcpListener; + + async fn spawn_mock_gateway(challenge_nonce: Option<&str>) -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("ws://{addr}"); + let nonce = challenge_nonce.map(String::from); + + let handle = tokio::spawn(async move { + if let Ok((stream, _)) = listener.accept().await { + let ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + let (mut sink, mut stream) = ws.split(); + + // Send challenge + let challenge = json!({ + "type": "event", + "event": "connect.challenge", + "payload": { "nonce": nonce } + }); + let _ = sink + .send(Message::Text(serde_json::to_string(&challenge).unwrap().into())) + .await; + + // Wait for connect request + while let Some(Ok(Message::Text(text))) = stream.next().await { + let frame: Value = serde_json::from_str(&text).unwrap(); + if frame["method"] == "connect" { + // Send hello-ok response + let res = json!({ + "type": "res", + "id": frame["id"], + "ok": true, + "payload": { + "protocol": 3, + "server": { "version": "1.0.0", "connId": "test-conn" }, + "policy": { "tickIntervalMs": 30000 }, + } + }); + let _ = sink + .send(Message::Text(serde_json::to_string(&res).unwrap().into())) + .await; + break; + } + } + + // Keep connection alive for subsequent requests + while let Some(Ok(Message::Text(text))) = stream.next().await { + let frame: Value = serde_json::from_str(&text).unwrap(); + if frame["type"] == "req" { + let method = frame["method"].as_str().unwrap_or(""); + let res = match method { + "sessions.reset" => json!({ + "type": "res", + "id": frame["id"], + "ok": true, + "payload": { + "key": "conv-1", + "sessionId": "sess-1" + } + }), + _ => json!({ + "type": "res", + "id": frame["id"], + "ok": true, + "payload": {} + }), + }; + let _ = sink + .send(Message::Text(serde_json::to_string(&res).unwrap().into())) + .await; + } + } + } + }); + + (url, handle) + } + + #[tokio::test] + async fn connect_and_handshake() { + let (url, _server) = spawn_mock_gateway(Some("test-nonce")).await; + let conn = OpenClawConnection::connect(&url, None, &generate_identity()) + .await + .unwrap() + .0; + assert!(conn.is_connected()); + conn.close().await; + } + + #[tokio::test] + async fn connect_without_challenge_nonce() { + let (url, _server) = spawn_mock_gateway(None).await; + let conn = OpenClawConnection::connect(&url, None, &generate_identity()) + .await + .unwrap() + .0; + assert!(conn.is_connected()); + conn.close().await; + } + + #[tokio::test] + async fn request_response_correlation() { + let (url, _server) = spawn_mock_gateway(None).await; + let conn = OpenClawConnection::connect(&url, None, &generate_identity()) + .await + .unwrap() + .0; + + let result: super::super::protocol::SessionsResetResponse = conn + .request("sessions.reset", json!({ "key": "conv-1", "reason": "new" })) + .await + .unwrap(); + + assert_eq!(result.key.as_deref(), Some("conv-1")); + assert_eq!(result.session_id.as_deref(), Some("sess-1")); + conn.close().await; + } + + #[tokio::test] + async fn event_broadcast() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("ws://{addr}"); + + let server = tokio::spawn(async move { + if let Ok((stream, _)) = listener.accept().await { + let ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + let (mut sink, mut stream) = ws.split(); + + // Send challenge + let challenge = json!({ + "type": "event", + "event": "connect.challenge", + "payload": {} + }); + let _ = sink + .send(Message::Text(serde_json::to_string(&challenge).unwrap().into())) + .await; + + // Wait for connect, respond + if let Some(Ok(Message::Text(text))) = stream.next().await { + let frame: Value = serde_json::from_str(&text).unwrap(); + let res = json!({ + "type": "res", + "id": frame["id"], + "ok": true, + "payload": { "protocol": 3 } + }); + let _ = sink + .send(Message::Text(serde_json::to_string(&res).unwrap().into())) + .await; + } + + // Brief delay so client has time to subscribe before event + tokio::time::sleep(Duration::from_millis(50)).await; + + // Send a chat event + let chat_event = json!({ + "type": "event", + "event": "chat", + "payload": { "state": "delta", "message": { "content": "hello" } } + }); + let _ = sink + .send(Message::Text(serde_json::to_string(&chat_event).unwrap().into())) + .await; + + // Keep alive briefly + tokio::time::sleep(Duration::from_millis(100)).await; + } + }); + + let conn = OpenClawConnection::connect(&url, None, &generate_identity()) + .await + .unwrap() + .0; + let mut event_rx = conn.subscribe_events().await; + + let event = tokio::time::timeout(Duration::from_secs(2), event_rx.recv()) + .await + .unwrap() + .unwrap(); + + assert_eq!(event.event, "chat"); + assert_eq!(event.payload.as_ref().unwrap()["state"].as_str(), Some("delta")); + + conn.close().await; + server.abort(); + } + + #[tokio::test] + async fn close_drops_event_sender() { + let (url, _server) = spawn_mock_gateway(None).await; + let conn = OpenClawConnection::connect(&url, None, &generate_identity()) + .await + .unwrap() + .0; + let mut event_rx = conn.subscribe_events().await; + + conn.close().await; + + assert!( + matches!(event_rx.recv().await, Err(broadcast::error::RecvError::Closed)), + "existing event receiver should close when connection closes" + ); + + let mut new_rx = conn.subscribe_events().await; + assert!( + matches!(new_rx.recv().await, Err(broadcast::error::RecvError::Closed)), + "new event receiver should also be closed after connection closes" + ); + } + + #[tokio::test] + async fn connection_failure_returns_error() { + let result = OpenClawConnection::connect("ws://127.0.0.1:1", None, &generate_identity()) + .await + .map(|(c, _)| c); + assert!(result.is_err()); + } +} diff --git a/crates/aionui-ai-agent/src/manager/openclaw/device_auth_store.rs b/crates/aionui-ai-agent/src/manager/openclaw/device_auth_store.rs new file mode 100644 index 000000000..c0a253887 --- /dev/null +++ b/crates/aionui-ai-agent/src/manager/openclaw/device_auth_store.rs @@ -0,0 +1,175 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use tracing::{debug, warn}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceAuthEntry { + pub token: String, + pub role: String, + pub scopes: Vec, + pub updated_at_ms: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeviceAuthStore { + version: u32, + device_id: String, + tokens: HashMap, +} + +fn store_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".openclaw") + .join("identity") + .join("device-auth.json") +} + +pub fn load_device_auth_token(device_id: &str, role: &str) -> Option { + let path = store_path(); + let content = fs::read_to_string(&path).ok()?; + let store: DeviceAuthStore = serde_json::from_str(&content).ok()?; + + if store.version != 1 || store.device_id != device_id { + return None; + } + + store.tokens.get(role).cloned() +} + +pub fn store_device_auth_token(device_id: &str, role: &str, token: &str, scopes: &[String]) { + let path = store_path(); + + let mut store = fs::read_to_string(&path) + .ok() + .and_then(|c| serde_json::from_str::(&c).ok()) + .filter(|s| s.version == 1 && s.device_id == device_id) + .unwrap_or_else(|| DeviceAuthStore { + version: 1, + device_id: device_id.to_owned(), + tokens: HashMap::new(), + }); + + let mut sorted_scopes: Vec = scopes.to_vec(); + sorted_scopes.sort(); + sorted_scopes.dedup(); + + store.tokens.insert( + role.to_owned(), + DeviceAuthEntry { + token: token.to_owned(), + role: role.to_owned(), + scopes: sorted_scopes, + updated_at_ms: aionui_common::now_ms(), + }, + ); + + if let Some(parent) = path.parent() + && let Err(e) = fs::create_dir_all(parent) + { + warn!(error = %e, "Failed to create device auth store directory"); + return; + } + + match serde_json::to_string_pretty(&store) { + Ok(json) => { + if let Err(e) = fs::write(&path, format!("{json}\n")) { + warn!(error = %e, "Failed to write device auth store"); + return; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600)); + } + debug!(role = role, "Stored device auth token"); + } + Err(e) => warn!(error = %e, "Failed to serialize device auth store"), + } +} + +pub fn clear_device_auth_token(device_id: &str, role: &str) { + let path = store_path(); + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return, + }; + let mut store: DeviceAuthStore = match serde_json::from_str(&content) { + Ok(s) => s, + Err(_) => return, + }; + + if store.version != 1 || store.device_id != device_id { + return; + } + + if store.tokens.remove(role).is_some() + && let Ok(json) = serde_json::to_string_pretty(&store) + { + let _ = fs::write(&path, format!("{json}\n")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_store_and_load() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("device-auth.json"); + + let store = DeviceAuthStore { + version: 1, + device_id: "dev-123".into(), + tokens: HashMap::from([( + "operator".into(), + DeviceAuthEntry { + token: "tok-abc".into(), + role: "operator".into(), + scopes: vec!["admin".into()], + updated_at_ms: 1700000000000, + }, + )]), + }; + + let json = serde_json::to_string_pretty(&store).unwrap(); + fs::write(&path, format!("{json}\n")).unwrap(); + + let content = fs::read_to_string(&path).unwrap(); + let loaded: DeviceAuthStore = serde_json::from_str(&content).unwrap(); + assert_eq!(loaded.device_id, "dev-123"); + assert_eq!(loaded.tokens["operator"].token, "tok-abc"); + } + + #[test] + fn scopes_sorted_and_deduped() { + let mut scopes: Vec = vec!["z".into(), "a".into(), "z".into(), "m".into()]; + scopes.sort(); + scopes.dedup(); + assert_eq!(scopes, vec!["a", "m", "z"]); + } + + #[test] + fn version_mismatch_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("device-auth.json"); + + let store = DeviceAuthStore { + version: 99, + device_id: "dev-123".into(), + tokens: HashMap::new(), + }; + let json = serde_json::to_string(&store).unwrap(); + fs::write(&path, &json).unwrap(); + + let content = fs::read_to_string(&path).unwrap(); + let loaded: DeviceAuthStore = serde_json::from_str(&content).unwrap(); + assert_ne!(loaded.version, 1); + } +} diff --git a/crates/aionui-ai-agent/src/manager/openclaw/device_identity.rs b/crates/aionui-ai-agent/src/manager/openclaw/device_identity.rs new file mode 100644 index 000000000..1e388eed9 --- /dev/null +++ b/crates/aionui-ai-agent/src/manager/openclaw/device_identity.rs @@ -0,0 +1,377 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::AgentError; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ed25519_dalek::{SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tracing::{debug, warn}; + +use super::protocol::{CLIENT_ID, CLIENT_MODE, DeviceAuthParams}; + +pub struct DeviceIdentity { + pub device_id: String, + pub signing_key: SigningKey, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredIdentity { + version: u32, + device_id: String, + public_key_pem: String, + private_key_pem: String, + #[serde(default)] + created_at_ms: Option, +} + +fn default_identity_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".openclaw") + .join("identity") + .join("device.json") +} + +pub fn load_or_create_identity(custom_path: Option<&Path>) -> Result { + let path = custom_path.map(PathBuf::from).unwrap_or_else(default_identity_path); + + if let Ok(identity) = load_identity(&path) { + return Ok(identity); + } + + let identity = generate_identity(); + if let Err(e) = save_identity(&path, &identity) { + warn!(error = %e, "Failed to save device identity, continuing with ephemeral key"); + } + Ok(identity) +} + +fn load_identity(path: &Path) -> Result { + let content = + fs::read_to_string(path).map_err(|e| AgentError::Internal(format!("Failed to read device identity: {e}")))?; + + let stored: StoredIdentity = serde_json::from_str(&content) + .map_err(|e| AgentError::Internal(format!("Failed to parse device identity: {e}")))?; + + if stored.version != 1 { + return Err(AgentError::Internal(format!( + "Unsupported device identity version: {}", + stored.version + ))); + } + + let signing_key = pem_to_signing_key(&stored.private_key_pem)?; + + let derived_id = derive_device_id(&signing_key.verifying_key()); + if derived_id != stored.device_id { + debug!("Device ID mismatch, using derived ID"); + } + + Ok(DeviceIdentity { + device_id: derived_id, + signing_key, + }) +} + +fn save_identity(path: &Path, identity: &DeviceIdentity) -> Result<(), AgentError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| AgentError::Internal(format!("Failed to create identity directory: {e}")))?; + } + + let (pub_pem, priv_pem) = signing_key_to_pem(&identity.signing_key); + + let stored = StoredIdentity { + version: 1, + device_id: identity.device_id.clone(), + public_key_pem: pub_pem, + private_key_pem: priv_pem, + created_at_ms: Some(aionui_common::now_ms()), + }; + + let json = serde_json::to_string_pretty(&stored) + .map_err(|e| AgentError::Internal(format!("Failed to serialize device identity: {e}")))?; + + fs::write(path, format!("{json}\n")) + .map_err(|e| AgentError::Internal(format!("Failed to write device identity: {e}")))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); + } + + Ok(()) +} + +pub(crate) fn generate_identity() -> DeviceIdentity { + let mut secret = [0u8; 32]; + getrandom::getrandom(&mut secret).expect("Failed to generate random bytes"); + let signing_key = SigningKey::from_bytes(&secret); + let device_id = derive_device_id(&signing_key.verifying_key()); + DeviceIdentity { device_id, signing_key } +} + +fn derive_device_id(verifying_key: &VerifyingKey) -> String { + let raw = verifying_key.as_bytes(); + let hash = Sha256::digest(raw); + hex::encode(hash) +} + +pub fn build_device_auth_params( + identity: &DeviceIdentity, + nonce: Option<&str>, + token: Option<&str>, +) -> DeviceAuthParams { + let role = "operator"; + let scopes = "operator.admin"; + let signed_at = aionui_common::now_ms(); + + let payload = build_auth_payload( + &identity.device_id, + CLIENT_ID, + CLIENT_MODE, + role, + scopes, + signed_at, + token, + nonce, + ); + + let signature = sign_payload(&identity.signing_key, &payload); + let public_key = public_key_base64url(&identity.signing_key); + + DeviceAuthParams { + id: identity.device_id.clone(), + public_key, + signature, + signed_at, + nonce: nonce.map(String::from), + } +} + +#[allow(clippy::too_many_arguments)] +fn build_auth_payload( + device_id: &str, + client_id: &str, + client_mode: &str, + role: &str, + scopes: &str, + signed_at_ms: i64, + token: Option<&str>, + nonce: Option<&str>, +) -> String { + let version = if nonce.is_some() { "v2" } else { "v1" }; + let token_str = token.unwrap_or(""); + + let signed_at_str = signed_at_ms.to_string(); + let mut parts = vec![ + version, + device_id, + client_id, + client_mode, + role, + scopes, + signed_at_str.as_str(), + token_str, + ]; + + let nonce_str; + if version == "v2" { + nonce_str = nonce.unwrap_or("").to_owned(); + parts.push(&nonce_str); + } + + parts.join("|") +} + +fn sign_payload(signing_key: &SigningKey, payload: &str) -> String { + use ed25519_dalek::Signer; + let signature = signing_key.sign(payload.as_bytes()); + URL_SAFE_NO_PAD.encode(signature.to_bytes()) +} + +fn public_key_base64url(signing_key: &SigningKey) -> String { + let vk = signing_key.verifying_key(); + let raw = vk.as_bytes(); + URL_SAFE_NO_PAD.encode(raw) +} + +// ── PEM Encoding/Decoding ─────────────────────────────────────────────── + +const ED25519_PKCS8_PREFIX: [u8; 16] = [ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, +]; + +const ED25519_SPKI_PREFIX: [u8; 12] = [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00]; + +fn pem_to_signing_key(pem: &str) -> Result { + let der = pem_decode(pem, "PRIVATE KEY")?; + + if der.len() == ED25519_PKCS8_PREFIX.len() + 32 && der[..ED25519_PKCS8_PREFIX.len()] == ED25519_PKCS8_PREFIX { + let raw: [u8; 32] = der[ED25519_PKCS8_PREFIX.len()..] + .try_into() + .map_err(|_| AgentError::Internal("Invalid Ed25519 private key length".into()))?; + return Ok(SigningKey::from_bytes(&raw)); + } + + Err(AgentError::Internal("Unrecognized Ed25519 PKCS8 format".into())) +} + +fn signing_key_to_pem(key: &SigningKey) -> (String, String) { + let mut priv_der = Vec::with_capacity(ED25519_PKCS8_PREFIX.len() + 32); + priv_der.extend_from_slice(&ED25519_PKCS8_PREFIX); + priv_der.extend_from_slice(key.as_bytes()); + let priv_pem = pem_encode(&priv_der, "PRIVATE KEY"); + + let mut pub_der = Vec::with_capacity(ED25519_SPKI_PREFIX.len() + 32); + pub_der.extend_from_slice(&ED25519_SPKI_PREFIX); + pub_der.extend_from_slice(key.verifying_key().as_bytes()); + let pub_pem = pem_encode(&pub_der, "PUBLIC KEY"); + + (pub_pem, priv_pem) +} + +fn pem_encode(der: &[u8], label: &str) -> String { + use base64::engine::general_purpose::STANDARD; + let b64 = STANDARD.encode(der); + let mut pem = format!("-----BEGIN {label}-----\n"); + for chunk in b64.as_bytes().chunks(64) { + pem.push_str(std::str::from_utf8(chunk).unwrap()); + pem.push('\n'); + } + pem.push_str(&format!("-----END {label}-----\n")); + pem +} + +fn pem_decode(pem: &str, label: &str) -> Result, AgentError> { + use base64::engine::general_purpose::STANDARD; + let begin = format!("-----BEGIN {label}-----"); + let end = format!("-----END {label}-----"); + + let b64: String = pem.lines().filter(|line| !line.starts_with("-----")).collect(); + + if !pem.contains(&begin) || !pem.contains(&end) { + return Err(AgentError::Internal(format!("Invalid PEM: missing {label} markers"))); + } + + STANDARD + .decode(b64) + .map_err(|e| AgentError::Internal(format!("Failed to decode PEM base64: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::Verifier; + + #[test] + fn generate_and_derive_id() { + let identity = generate_identity(); + assert_eq!(identity.device_id.len(), 64); + let re_derived = derive_device_id(&identity.signing_key.verifying_key()); + assert_eq!(identity.device_id, re_derived); + } + + #[test] + fn sign_and_verify_roundtrip() { + let identity = generate_identity(); + let payload = "v2|device123|gateway-client|backend|operator|operator.admin|1700000000000||nonce123"; + let signature_b64 = sign_payload(&identity.signing_key, payload); + + let sig_bytes = URL_SAFE_NO_PAD.decode(&signature_b64).unwrap(); + let signature = ed25519_dalek::Signature::from_bytes(sig_bytes.as_slice().try_into().unwrap()); + identity + .signing_key + .verifying_key() + .verify(payload.as_bytes(), &signature) + .unwrap(); + } + + #[test] + fn public_key_base64url_is_correct_length() { + let identity = generate_identity(); + let b64 = public_key_base64url(&identity.signing_key); + let raw = URL_SAFE_NO_PAD.decode(&b64).unwrap(); + assert_eq!(raw.len(), 32); + } + + #[test] + fn build_auth_payload_v1() { + let payload = build_auth_payload( + "abc123", + "gateway-client", + "backend", + "operator", + "operator.admin", + 1700000000000, + None, + None, + ); + assert_eq!( + payload, + "v1|abc123|gateway-client|backend|operator|operator.admin|1700000000000|" + ); + } + + #[test] + fn build_auth_payload_v2_with_nonce() { + let payload = build_auth_payload( + "abc123", + "gateway-client", + "backend", + "operator", + "operator.admin", + 1700000000000, + Some("tok"), + Some("nonce123"), + ); + assert_eq!( + payload, + "v2|abc123|gateway-client|backend|operator|operator.admin|1700000000000|tok|nonce123" + ); + } + + #[test] + fn pem_roundtrip() { + let identity = generate_identity(); + let (pub_pem, priv_pem) = signing_key_to_pem(&identity.signing_key); + + assert!(pub_pem.starts_with("-----BEGIN PUBLIC KEY-----")); + assert!(priv_pem.starts_with("-----BEGIN PRIVATE KEY-----")); + + let recovered = pem_to_signing_key(&priv_pem).unwrap(); + assert_eq!(identity.signing_key.as_bytes(), recovered.as_bytes()); + } + + #[test] + fn save_and_load_identity() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("device.json"); + + let identity = generate_identity(); + save_identity(&path, &identity).unwrap(); + let loaded = load_identity(&path).unwrap(); + + assert_eq!(identity.device_id, loaded.device_id); + assert_eq!(identity.signing_key.as_bytes(), loaded.signing_key.as_bytes()); + } + + #[test] + fn build_device_auth_params_produces_valid_signature() { + let identity = generate_identity(); + let params = build_device_auth_params(&identity, Some("nonce-x"), None); + + assert_eq!(params.id, identity.device_id); + assert_eq!(params.nonce.as_deref(), Some("nonce-x")); + + let pub_raw = URL_SAFE_NO_PAD.decode(¶ms.public_key).unwrap(); + assert_eq!(pub_raw.len(), 32); + + let sig_bytes = URL_SAFE_NO_PAD.decode(¶ms.signature).unwrap(); + assert_eq!(sig_bytes.len(), 64); + } +} diff --git a/crates/aionui-ai-agent/src/manager/openclaw/event_mapper.rs b/crates/aionui-ai-agent/src/manager/openclaw/event_mapper.rs new file mode 100644 index 000000000..1e4ce5281 --- /dev/null +++ b/crates/aionui-ai-agent/src/manager/openclaw/event_mapper.rs @@ -0,0 +1,643 @@ +use aionui_common::Confirmation; +use serde_json::Value; +use tracing::debug; + +use super::protocol::{AgentEvent, ApprovalRequestEvent, ChatEvent, ChatEventState, EventFrame}; +use crate::protocol::events::{ + AcpPermissionEventData, AgentStreamEvent, ErrorEventData, FinishEventData, StartEventData, TextEventData, + ThinkingEventData, ToolCallEventData, ToolCallStatus, +}; + +#[derive(Default)] +pub struct TextFallbackState { + pub accumulated_text: String, + pub agent_assistant_fallback: String, + pub turn_active: bool, + pub current_msg_id: Option, + pub current_run_id: Option, + pub current_session_key: Option, +} + +impl TextFallbackState { + pub fn new() -> Self { + Self::default() + } + + pub fn reset_for_new_turn(&mut self) { + self.accumulated_text.clear(); + self.agent_assistant_fallback.clear(); + self.turn_active = true; + self.current_msg_id = None; + self.current_run_id = None; + } +} + +pub fn map_openclaw_event( + event: &EventFrame, + text_state: &mut TextFallbackState, + our_session_key: Option<&str>, +) -> Vec { + let event_name = event.event.as_str(); + + match event_name { + "chat" | "chat.event" => map_chat_event(event, text_state, our_session_key), + "agent" | "agent.event" => map_agent_event(event, text_state, our_session_key), + "exec.approval.request" => map_approval_event(event), + "tick" | "health" | "shutdown" | "connect.challenge" => vec![], + _ => { + debug!(event = event_name, "Unhandled OpenClaw event type"); + vec![] + } + } +} + +fn map_chat_event( + event: &EventFrame, + text_state: &mut TextFallbackState, + our_session_key: Option<&str>, +) -> Vec { + let payload = match event.payload.as_ref() { + Some(p) => p, + None => return vec![], + }; + + let chat: ChatEvent = match serde_json::from_value(payload.clone()) { + Ok(c) => c, + Err(_) => return vec![], + }; + + if is_from_other_session(chat.session_key.as_deref(), our_session_key) { + return vec![]; + } + + if let Some(ref run_id) = chat.run_id { + text_state.current_run_id = Some(run_id.clone()); + } + if let Some(ref sk) = chat.session_key { + text_state.current_session_key = Some(sk.clone()); + } + + let mut events = Vec::new(); + + match chat.state { + ChatEventState::Delta => { + if !text_state.turn_active { + text_state.reset_for_new_turn(); + events.push(AgentStreamEvent::Start(StartEventData { + session_id: chat.session_key.clone(), + })); + } + + // v4 schema delivers the incremental chunk in `deltaText` (with optional + // `replace=true` meaning the whole accumulated text should be reset to it). + // v3 schema instead sends cumulative text on `message` — we diff it. + let delta = if let Some(delta_text) = chat.delta_text.as_deref() { + if chat.replace == Some(true) { + text_state.accumulated_text = delta_text.to_owned(); + } else { + text_state.accumulated_text.push_str(delta_text); + } + (!delta_text.is_empty()).then(|| delta_text.to_owned()) + } else { + compute_text_delta(&chat.message, &mut text_state.accumulated_text) + }; + + if let Some(delta) = delta { + if text_state.current_msg_id.is_none() { + text_state.current_msg_id = Some(uuid::Uuid::new_v4().to_string()); + } + events.push(AgentStreamEvent::Text(TextEventData { content: delta })); + } + } + ChatEventState::Final => { + if text_state.accumulated_text.is_empty() && !text_state.agent_assistant_fallback.is_empty() { + // Layer 2 fallback: use agent.assistant buffered text + if text_state.current_msg_id.is_none() { + text_state.current_msg_id = Some(uuid::Uuid::new_v4().to_string()); + } + events.push(AgentStreamEvent::Text(TextEventData { + content: text_state.agent_assistant_fallback.clone(), + })); + } + + events.push(AgentStreamEvent::Finish(FinishEventData { + session_id: chat.session_key, + })); + text_state.turn_active = false; + } + ChatEventState::Aborted => { + events.push(AgentStreamEvent::Finish(FinishEventData { + session_id: chat.session_key, + })); + text_state.turn_active = false; + } + ChatEventState::Error => { + let msg = chat.error_message.unwrap_or_else(|| "Unknown chat error".into()); + events.push(AgentStreamEvent::Error(ErrorEventData::legacy(msg, None))); + text_state.turn_active = false; + } + } + + events +} + +fn map_agent_event( + event: &EventFrame, + text_state: &mut TextFallbackState, + our_session_key: Option<&str>, +) -> Vec { + let payload = match event.payload.as_ref() { + Some(p) => p, + None => return vec![], + }; + + let agent_evt: AgentEvent = match serde_json::from_value(payload.clone()) { + Ok(e) => e, + Err(_) => return vec![], + }; + + if is_from_other_session(agent_evt.session_key.as_deref(), our_session_key) { + return vec![]; + } + + let stream = agent_evt.stream.as_str(); + let data = &agent_evt.data; + + match stream { + "thinking" | "thought" => { + let content = data.get("text").and_then(|v| v.as_str()).unwrap_or("").to_owned(); + if content.is_empty() { + return vec![]; + } + vec![AgentStreamEvent::Thinking(ThinkingEventData { + content, + subject: data.get("subject").and_then(|v| v.as_str()).map(String::from), + duration: None, + status: Some("in_progress".into()), + })] + } + "tool" | "tool_call" => map_tool_event(data), + "assistant" => { + // Layer 2 buffer: accumulate for fallback + if let Some(text) = data.get("text").and_then(|v| v.as_str()) { + text_state.agent_assistant_fallback.push_str(text); + } + vec![] + } + // Turn lifecycle is driven by chat.state events (final/aborted/error) + "lifecycle" => vec![], + _ => { + debug!(stream = stream, "Unhandled agent event stream"); + vec![] + } + } +} + +fn map_tool_event(data: &Value) -> Vec { + let phase = data.get("phase").and_then(|v| v.as_str()).unwrap_or(""); + let is_error = data.get("isError").and_then(|v| v.as_bool()).unwrap_or(false); + + let status = match phase { + "result" if is_error => ToolCallStatus::Error, + "result" => ToolCallStatus::Completed, + _ => ToolCallStatus::Running, + }; + + let call_id = data.get("toolCallId").and_then(|v| v.as_str()).unwrap_or("").to_owned(); + let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("").to_owned(); + let args = data.get("args").cloned().unwrap_or_default(); + + vec![AgentStreamEvent::ToolCall(ToolCallEventData { + call_id, + name, + args, + status, + input: None, + output: None, + description: None, + })] +} + +fn map_approval_event(event: &EventFrame) -> Vec { + let payload = match event.payload.as_ref() { + Some(p) => p, + None => return vec![], + }; + + let approval: ApprovalRequestEvent = match serde_json::from_value(payload.clone()) { + Ok(a) => a, + Err(_) => return vec![], + }; + + let tool_call = approval.tool_call.as_ref(); + let call_id = tool_call + .and_then(|tc| tc.tool_call_id.as_deref()) + .unwrap_or(&approval.request_id) + .to_owned(); + + let confirmation = Confirmation { + id: approval.request_id.clone(), + call_id, + title: tool_call.and_then(|tc| tc.title.clone()), + action: tool_call.and_then(|tc| tc.title.clone()), + description: String::new(), + command_type: tool_call.and_then(|tc| tc.kind.as_deref()).map(ToOwned::to_owned), + options: Vec::new(), + }; + + vec![AgentStreamEvent::AcpPermission(AcpPermissionEventData::Confirmation( + confirmation, + ))] +} + +fn compute_text_delta(message: &Option, accumulated: &mut String) -> Option { + let msg = message.as_ref()?; + let cumulative_text = extract_text_from_message(msg)?; + + if cumulative_text.len() <= accumulated.len() { + return None; + } + + let delta = cumulative_text[accumulated.len()..].to_owned(); + *accumulated = cumulative_text; + Some(delta) +} + +fn extract_text_from_message(message: &Value) -> Option { + // Format 1: { content: "string" } + if let Some(s) = message.get("content").and_then(|v| v.as_str()) { + return Some(s.to_owned()); + } + + // Format 2: { content: [{ type: "text", text: "..." }, ...] } + if let Some(blocks) = message.get("content").and_then(|v| v.as_array()) { + let text: String = blocks + .iter() + .filter_map(|b| { + if b.get("type").and_then(|t| t.as_str()) == Some("text") { + b.get("text").and_then(|t| t.as_str()) + } else { + None + } + }) + .collect(); + if !text.is_empty() { + return Some(text); + } + } + + // Format 3: { text: "string" } + if let Some(s) = message.get("text").and_then(|v| v.as_str()) { + return Some(s.to_owned()); + } + + None +} + +fn is_from_other_session(event_session: Option<&str>, our_session: Option<&str>) -> bool { + match (event_session, our_session) { + (Some(event_sk), Some(our_sk)) => event_sk != our_sk, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn make_event(event: &str, payload: Value) -> EventFrame { + EventFrame { + event: event.into(), + payload: Some(payload), + seq: None, + } + } + + #[test] + fn chat_delta_produces_text_event() { + let mut state = TextFallbackState::new(); + state.reset_for_new_turn(); + + let event = make_event( + "chat", + json!({ "state": "delta", "message": { "content": "Hello" }, "sessionKey": "sk-1" }), + ); + let events = map_openclaw_event(&event, &mut state, Some("sk-1")); + + assert_eq!(events.len(), 1); + assert!(matches!(&events[0], AgentStreamEvent::Text(d) if d.content == "Hello")); + assert_eq!(state.accumulated_text, "Hello"); + } + + #[test] + fn chat_delta_v4_uses_delta_text() { + let mut state = TextFallbackState::new(); + state.reset_for_new_turn(); + + let e1 = make_event("chat", json!({ "state": "delta", "deltaText": "He" })); + let e2 = make_event("chat", json!({ "state": "delta", "deltaText": "llo" })); + + let events1 = map_openclaw_event(&e1, &mut state, None); + assert_eq!(events1.len(), 1); + assert!(matches!(&events1[0], AgentStreamEvent::Text(d) if d.content == "He")); + + let events2 = map_openclaw_event(&e2, &mut state, None); + assert_eq!(events2.len(), 1); + assert!(matches!(&events2[0], AgentStreamEvent::Text(d) if d.content == "llo")); + assert_eq!(state.accumulated_text, "Hello"); + } + + #[test] + fn chat_delta_v4_replace_resets_buffer() { + let mut state = TextFallbackState::new(); + state.reset_for_new_turn(); + state.accumulated_text = "stale draft".into(); + + let event = make_event( + "chat", + json!({ "state": "delta", "deltaText": "fresh", "replace": true }), + ); + let events = map_openclaw_event(&event, &mut state, None); + + assert_eq!(events.len(), 1); + assert!(matches!(&events[0], AgentStreamEvent::Text(d) if d.content == "fresh")); + assert_eq!(state.accumulated_text, "fresh"); + } + + #[test] + fn chat_delta_computes_incremental() { + let mut state = TextFallbackState::new(); + state.reset_for_new_turn(); + + let e1 = make_event("chat", json!({ "state": "delta", "message": { "content": "He" } })); + let e2 = make_event("chat", json!({ "state": "delta", "message": { "content": "Hello" } })); + + let events1 = map_openclaw_event(&e1, &mut state, None); + assert_eq!(events1.len(), 1); + assert!(matches!(&events1[0], AgentStreamEvent::Text(d) if d.content == "He")); + + let events2 = map_openclaw_event(&e2, &mut state, None); + assert_eq!(events2.len(), 1); + assert!(matches!(&events2[0], AgentStreamEvent::Text(d) if d.content == "llo")); + } + + #[test] + fn chat_final_produces_finish() { + let mut state = TextFallbackState::new(); + state.reset_for_new_turn(); + + let event = make_event("chat", json!({ "state": "final", "sessionKey": "sk-1" })); + let events = map_openclaw_event(&event, &mut state, None); + + assert_eq!(events.len(), 1); + assert!(matches!(&events[0], AgentStreamEvent::Finish(_))); + assert!(!state.turn_active); + } + + #[test] + fn chat_final_uses_layer2_fallback() { + let mut state = TextFallbackState::new(); + state.reset_for_new_turn(); + state.agent_assistant_fallback = "Fallback text".into(); + + let event = make_event("chat", json!({ "state": "final" })); + let events = map_openclaw_event(&event, &mut state, None); + + assert_eq!(events.len(), 2); + assert!(matches!(&events[0], AgentStreamEvent::Text(d) if d.content == "Fallback text")); + assert!(matches!(&events[1], AgentStreamEvent::Finish(_))); + } + + #[test] + fn chat_error_produces_error_event() { + let mut state = TextFallbackState::new(); + state.reset_for_new_turn(); + + let event = make_event("chat", json!({ "state": "error", "errorMessage": "rate limit" })); + let events = map_openclaw_event(&event, &mut state, None); + + assert_eq!(events.len(), 1); + assert!(matches!(&events[0], AgentStreamEvent::Error(d) if d.message == "rate limit")); + } + + #[test] + fn chat_aborted_produces_finish() { + let mut state = TextFallbackState::new(); + state.reset_for_new_turn(); + + let event = make_event("chat", json!({ "state": "aborted" })); + let events = map_openclaw_event(&event, &mut state, None); + + assert_eq!(events.len(), 1); + assert!(matches!(&events[0], AgentStreamEvent::Finish(_))); + } + + #[test] + fn agent_thinking_produces_thinking_event() { + let mut state = TextFallbackState::new(); + let event = make_event( + "agent.event", + json!({ "stream": "thinking", "data": { "text": "Analyzing..." } }), + ); + let events = map_openclaw_event(&event, &mut state, None); + + assert_eq!(events.len(), 1); + assert!(matches!(&events[0], AgentStreamEvent::Thinking(d) if d.content == "Analyzing...")); + } + + #[test] + fn agent_tool_start_produces_running() { + let mut state = TextFallbackState::new(); + let event = make_event( + "agent.event", + json!({ + "stream": "tool", + "data": { + "phase": "start", + "toolCallId": "tc-1", + "name": "read_file", + "args": { "path": "/tmp/test" } + } + }), + ); + let events = map_openclaw_event(&event, &mut state, None); + + assert_eq!(events.len(), 1); + if let AgentStreamEvent::ToolCall(tc) = &events[0] { + assert_eq!(tc.call_id, "tc-1"); + assert_eq!(tc.name, "read_file"); + assert_eq!(tc.status, ToolCallStatus::Running); + } else { + panic!("Expected ToolCall"); + } + } + + #[test] + fn agent_tool_result_produces_completed() { + let mut state = TextFallbackState::new(); + let event = make_event( + "agent.event", + json!({ + "stream": "tool", + "data": { "phase": "result", "toolCallId": "tc-1", "name": "read_file" } + }), + ); + let events = map_openclaw_event(&event, &mut state, None); + + assert_eq!(events.len(), 1); + assert!(matches!(&events[0], AgentStreamEvent::ToolCall(tc) if tc.status == ToolCallStatus::Completed)); + } + + #[test] + fn agent_tool_error_produces_error_status() { + let mut state = TextFallbackState::new(); + let event = make_event( + "agent.event", + json!({ + "stream": "tool", + "data": { "phase": "result", "isError": true, "toolCallId": "tc-1", "name": "bash" } + }), + ); + let events = map_openclaw_event(&event, &mut state, None); + + assert_eq!(events.len(), 1); + assert!(matches!(&events[0], AgentStreamEvent::ToolCall(tc) if tc.status == ToolCallStatus::Error)); + } + + #[test] + fn agent_assistant_buffers_for_fallback() { + let mut state = TextFallbackState::new(); + state.reset_for_new_turn(); + + let event = make_event( + "agent.event", + json!({ "stream": "assistant", "data": { "text": "buffered" } }), + ); + let events = map_openclaw_event(&event, &mut state, None); + + assert!(events.is_empty()); + assert_eq!(state.agent_assistant_fallback, "buffered"); + } + + #[test] + fn approval_request_produces_permission() { + let mut state = TextFallbackState::new(); + let event = make_event( + "exec.approval.request", + json!({ + "requestId": "req-1", + "toolCall": { "toolCallId": "tc-1", "title": "bash", "kind": "execute" } + }), + ); + let events = map_openclaw_event(&event, &mut state, None); + + assert_eq!(events.len(), 1); + if let AgentStreamEvent::AcpPermission(AcpPermissionEventData::Confirmation(conf)) = &events[0] { + assert_eq!(conf.call_id, "tc-1"); + assert_eq!(conf.action, Some("bash".to_owned())); + assert_eq!(conf.id, "req-1"); + } else { + panic!("Expected AcpPermission(Confirmation)"); + } + } + + #[test] + fn session_filtering_skips_other_sessions() { + let mut state = TextFallbackState::new(); + state.reset_for_new_turn(); + + let event = make_event( + "chat", + json!({ "state": "delta", "sessionKey": "other-session", "message": { "content": "x" } }), + ); + let events = map_openclaw_event(&event, &mut state, Some("my-session")); + + assert!(events.is_empty()); + } + + #[test] + fn session_filtering_passes_matching_session() { + let mut state = TextFallbackState::new(); + state.reset_for_new_turn(); + + let event = make_event( + "chat", + json!({ "state": "delta", "sessionKey": "my-session", "message": { "content": "x" } }), + ); + let events = map_openclaw_event(&event, &mut state, Some("my-session")); + + assert!(!events.is_empty()); + } + + #[test] + fn session_filtering_passes_when_no_session_key() { + let mut state = TextFallbackState::new(); + state.reset_for_new_turn(); + + let event = make_event("chat", json!({ "state": "delta", "message": { "content": "x" } })); + let events = map_openclaw_event(&event, &mut state, Some("my-session")); + + assert!(!events.is_empty()); + } + + #[test] + fn extract_text_string_content() { + let msg = json!({ "content": "hello world" }); + assert_eq!(extract_text_from_message(&msg), Some("hello world".into())); + } + + #[test] + fn extract_text_block_content() { + let msg = json!({ "content": [ + { "type": "text", "text": "part1" }, + { "type": "image", "url": "..." }, + { "type": "text", "text": "part2" } + ]}); + assert_eq!(extract_text_from_message(&msg), Some("part1part2".into())); + } + + #[test] + fn extract_text_from_text_field() { + let msg = json!({ "text": "fallback text" }); + assert_eq!(extract_text_from_message(&msg), Some("fallback text".into())); + } + + #[test] + fn extract_text_returns_none_for_empty() { + let msg = json!({}); + assert_eq!(extract_text_from_message(&msg), None); + } + + #[test] + fn tick_and_health_events_ignored() { + let mut state = TextFallbackState::new(); + let tick = EventFrame { + event: "tick".into(), + payload: Some(json!({ "ts": 12345 })), + seq: None, + }; + assert!(map_openclaw_event(&tick, &mut state, None).is_empty()); + + let health = EventFrame { + event: "health".into(), + payload: None, + seq: None, + }; + assert!(map_openclaw_event(&health, &mut state, None).is_empty()); + } + + #[test] + fn first_delta_auto_starts_turn() { + let mut state = TextFallbackState::new(); + assert!(!state.turn_active); + + let event = make_event("chat", json!({ "state": "delta", "message": { "content": "Hi" } })); + let events = map_openclaw_event(&event, &mut state, None); + + assert_eq!(events.len(), 2); + assert!(matches!(&events[0], AgentStreamEvent::Start(_))); + assert!(matches!(&events[1], AgentStreamEvent::Text(_))); + assert!(state.turn_active); + } +} diff --git a/crates/aionui-ai-agent/src/manager/openclaw/mod.rs b/crates/aionui-ai-agent/src/manager/openclaw/mod.rs new file mode 100644 index 000000000..efefc063a --- /dev/null +++ b/crates/aionui-ai-agent/src/manager/openclaw/mod.rs @@ -0,0 +1,9 @@ +pub mod agent; +pub mod config; +pub mod connection; +pub mod device_auth_store; +pub mod device_identity; +pub mod event_mapper; +pub mod protocol; + +pub use agent::OpenClawAgentManager; diff --git a/crates/aionui-ai-agent/src/manager/openclaw/protocol.rs b/crates/aionui-ai-agent/src/manager/openclaw/protocol.rs new file mode 100644 index 000000000..2be945f62 --- /dev/null +++ b/crates/aionui-ai-agent/src/manager/openclaw/protocol.rs @@ -0,0 +1,528 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// Negotiated protocol range. Gateway 2026.5.12+ requires v4 (chat events become a +// discriminated union with required `deltaText` on delta frames); older Gateways still +// speak v3. Advertising `min=3, max=4` lets the same client connect to both. +pub const OPENCLAW_MIN_PROTOCOL_VERSION: u32 = 3; +pub const OPENCLAW_MAX_PROTOCOL_VERSION: u32 = 4; + +pub const CLIENT_ID: &str = "gateway-client"; +pub const CLIENT_DISPLAY_NAME: &str = "AionUI-Backend"; +pub const CLIENT_MODE: &str = "backend"; +pub const CLIENT_VERSION: &str = "1.0.0"; + +// ── WebSocket Frame Types ─────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct RequestFrame { + #[serde(rename = "type")] + pub type_: &'static str, + pub id: String, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ResponseFrame { + pub id: String, + pub ok: bool, + #[serde(default)] + pub payload: Option, + #[serde(default)] + pub error: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct EventFrame { + pub event: String, + #[serde(default)] + pub payload: Option, + #[serde(default)] + pub seq: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ErrorShape { + pub code: String, + pub message: String, + #[serde(default)] + pub details: Option, + #[serde(default)] + pub retryable: Option, + #[serde(default, rename = "retryAfterMs")] + pub retry_after_ms: Option, +} + +/// Discriminator for incoming WebSocket messages. +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum IncomingFrame { + Res(ResponseFrame), + Event(EventFrame), +} + +// ── Connect Handshake ─────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectParams { + pub min_protocol: u32, + pub max_protocol: u32, + pub client: ClientInfo, + pub caps: Vec<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scopes: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub auth: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub device: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientInfo { + pub id: &'static str, + pub display_name: &'static str, + pub version: &'static str, + pub platform: &'static str, + pub mode: &'static str, +} + +#[derive(Debug, Serialize)] +pub struct AuthParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceAuthParams { + pub id: String, + pub public_key: String, + pub signature: String, + pub signed_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option, +} + +#[derive(Debug, Deserialize)] +pub struct HelloOk { + #[serde(default)] + pub protocol: Option, + #[serde(default)] + pub server: Option, + #[serde(default)] + pub policy: Option, + #[serde(default)] + pub auth: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerInfo { + #[serde(default)] + pub version: Option, + #[serde(default)] + pub conn_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolicyInfo { + #[serde(default)] + pub max_payload: Option, + #[serde(default)] + pub tick_interval_ms: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HelloAuthInfo { + #[serde(default)] + pub device_token: Option, + #[serde(default)] + pub role: Option, + #[serde(default)] + pub scopes: Option>, +} + +// ── Session Management ────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct SessionsResolveParams { + pub key: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionsResolveResponse { + pub key: String, + #[serde(default)] + pub session_id: Option, +} + +#[derive(Debug, Serialize)] +pub struct SessionsResetParams { + pub key: String, + pub reason: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionsResetResponse { + #[serde(default)] + pub key: Option, + #[serde(default)] + pub session_id: Option, +} + +// ── Chat Operations ───────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatSendParams { + pub session_key: String, + pub message: String, + pub idempotency_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatAbortParams { + pub session_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub run_id: Option, +} + +// ── Gateway Events ────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatEvent { + #[serde(default)] + pub run_id: Option, + #[serde(default)] + pub session_key: Option, + #[serde(default)] + pub seq: Option, + pub state: ChatEventState, + #[serde(default)] + pub message: Option, + /// v4-only: incremental delta text on `state == "delta"` frames. Required by the + /// v4 schema (`ChatDeltaEventSchema`), absent on v3 Gateways. When present it is + /// the authoritative delta — `message` may be missing or carry only metadata. + #[serde(default)] + pub delta_text: Option, + /// v4-only: when true the delta replaces the accumulated text instead of appending. + #[serde(default)] + pub replace: Option, + #[serde(default)] + pub error_message: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ChatEventState { + Delta, + Final, + Aborted, + Error, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentEvent { + #[serde(default)] + pub run_id: Option, + #[serde(default)] + pub session_key: Option, + #[serde(default)] + pub seq: Option, + pub stream: String, + #[serde(default)] + pub data: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalRequestEvent { + pub request_id: String, + #[serde(default)] + pub tool_call: Option, + #[serde(default)] + pub options: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalToolCall { + #[serde(default)] + pub tool_call_id: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub kind: Option, + #[serde(default)] + pub raw_input: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalOption { + pub option_id: String, + pub name: String, + pub kind: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalRespondParams { + pub request_id: String, + pub option_id: String, +} + +// ── Challenge Event ───────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct ChallengePayload { + #[serde(default)] + pub nonce: Option, +} + +// ── URL Normalization ─────────────────────────────────────────────────── + +pub fn normalize_ws_url(host: &str, port: u16) -> String { + let raw = if host.contains("://") { + format!("{host}:{port}") + } else { + format!("ws://{host}:{port}") + }; + + raw.replace("https://", "wss://").replace("http://", "ws://") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_ws_url_bare_host() { + assert_eq!(normalize_ws_url("127.0.0.1", 18789), "ws://127.0.0.1:18789"); + assert_eq!(normalize_ws_url("localhost", 9999), "ws://localhost:9999"); + } + + #[test] + fn normalize_ws_url_with_scheme() { + assert_eq!(normalize_ws_url("https://remote.host", 443), "wss://remote.host:443"); + assert_eq!(normalize_ws_url("http://local.host", 8080), "ws://local.host:8080"); + assert_eq!(normalize_ws_url("ws://already.ws", 18789), "ws://already.ws:18789"); + } + + #[test] + fn request_frame_serializes() { + let frame = RequestFrame { + type_: "req", + id: "abc-123".into(), + method: "connect".into(), + params: Some(serde_json::json!({"key": "value"})), + }; + let json = serde_json::to_value(&frame).unwrap(); + assert_eq!(json["type"], "req"); + assert_eq!(json["id"], "abc-123"); + assert_eq!(json["method"], "connect"); + } + + #[test] + fn response_frame_deserializes_ok() { + let json = serde_json::json!({ + "id": "abc-123", + "ok": true, + "payload": { "protocol": 3 } + }); + let frame: ResponseFrame = serde_json::from_value(json).unwrap(); + assert!(frame.ok); + assert_eq!(frame.id, "abc-123"); + assert!(frame.payload.is_some()); + assert!(frame.error.is_none()); + } + + #[test] + fn response_frame_deserializes_error() { + let json = serde_json::json!({ + "id": "abc-123", + "ok": false, + "error": { "code": "AUTH_FAILED", "message": "bad token" } + }); + let frame: ResponseFrame = serde_json::from_value(json).unwrap(); + assert!(!frame.ok); + let err = frame.error.unwrap(); + assert_eq!(err.code, "AUTH_FAILED"); + } + + #[test] + fn incoming_frame_dispatch() { + let res_json = serde_json::json!({ + "type": "res", + "id": "x", + "ok": true, + }); + let parsed: IncomingFrame = serde_json::from_value(res_json).unwrap(); + assert!(matches!(parsed, IncomingFrame::Res(_))); + + let evt_json = serde_json::json!({ + "type": "event", + "event": "chat", + "payload": {}, + }); + let parsed: IncomingFrame = serde_json::from_value(evt_json).unwrap(); + assert!(matches!(parsed, IncomingFrame::Event(_))); + } + + #[test] + fn chat_event_state_deserializes() { + let json = serde_json::json!({ + "state": "delta", + "message": { "content": "hello" }, + }); + let event: ChatEvent = serde_json::from_value(json).unwrap(); + assert_eq!(event.state, ChatEventState::Delta); + assert!(event.delta_text.is_none()); + assert!(event.replace.is_none()); + } + + #[test] + fn chat_event_v4_delta_with_delta_text() { + let json = serde_json::json!({ + "runId": "run-1", + "sessionKey": "sk-1", + "seq": 0, + "state": "delta", + "deltaText": "Hello", + "replace": false, + }); + let event: ChatEvent = serde_json::from_value(json).unwrap(); + assert_eq!(event.state, ChatEventState::Delta); + assert_eq!(event.delta_text.as_deref(), Some("Hello")); + assert_eq!(event.replace, Some(false)); + assert!(event.message.is_none()); + } + + #[test] + fn connect_params_serializes() { + let params = ConnectParams { + min_protocol: OPENCLAW_MIN_PROTOCOL_VERSION, + max_protocol: OPENCLAW_MAX_PROTOCOL_VERSION, + client: ClientInfo { + id: CLIENT_ID, + display_name: CLIENT_DISPLAY_NAME, + version: CLIENT_VERSION, + platform: "darwin", + mode: CLIENT_MODE, + }, + caps: vec!["tool-events"], + role: Some("operator".into()), + scopes: Some(vec!["operator.admin".into()]), + auth: None, + device: None, + }; + let json = serde_json::to_value(¶ms).unwrap(); + assert_eq!(json["minProtocol"], 3); + assert_eq!(json["maxProtocol"], 4); + assert_eq!(json["client"]["id"], "gateway-client"); + assert_eq!(json["caps"][0], "tool-events"); + } + + #[test] + fn hello_ok_deserializes_minimal() { + let json = serde_json::json!({}); + let hello: HelloOk = serde_json::from_value(json).unwrap(); + assert!(hello.protocol.is_none()); + assert!(hello.policy.is_none()); + } + + #[test] + fn hello_ok_deserializes_full() { + let json = serde_json::json!({ + "type": "hello-ok", + "protocol": 3, + "server": { "version": "1.2.0", "connId": "conn-1" }, + "policy": { "tickIntervalMs": 30000 }, + "auth": { "deviceToken": "tok123", "role": "operator" }, + }); + let hello: HelloOk = serde_json::from_value(json).unwrap(); + assert_eq!(hello.protocol, Some(3)); + assert_eq!(hello.policy.as_ref().unwrap().tick_interval_ms, Some(30000)); + assert_eq!(hello.auth.as_ref().unwrap().device_token.as_deref(), Some("tok123")); + } + + #[test] + fn sessions_resolve_serializes() { + let params = SessionsResolveParams { key: "sk-prev".into() }; + let json = serde_json::to_value(¶ms).unwrap(); + assert_eq!(json["key"], "sk-prev"); + } + + #[test] + fn sessions_resolve_response_deserializes() { + let json = serde_json::json!({ + "key": "sk-resolved", + "sessionId": "sess-42" + }); + let resp: SessionsResolveResponse = serde_json::from_value(json).unwrap(); + assert_eq!(resp.key, "sk-resolved"); + assert_eq!(resp.session_id.unwrap(), "sess-42"); + } + + #[test] + fn sessions_reset_serializes() { + let params = SessionsResetParams { + key: "conv-1".into(), + reason: "new".into(), + }; + let json = serde_json::to_value(¶ms).unwrap(); + assert_eq!(json["key"], "conv-1"); + assert_eq!(json["reason"], "new"); + } + + #[test] + fn chat_send_params_serializes() { + let params = ChatSendParams { + session_key: "sk-1".into(), + message: "hello".into(), + idempotency_key: "idem-1".into(), + attachments: None, + }; + let json = serde_json::to_value(¶ms).unwrap(); + assert_eq!(json["sessionKey"], "sk-1"); + assert_eq!(json["message"], "hello"); + assert_eq!(json["idempotencyKey"], "idem-1"); + assert!(json.get("attachments").is_none()); + } + + #[test] + fn approval_request_deserializes() { + let json = serde_json::json!({ + "requestId": "req-1", + "toolCall": { + "toolCallId": "tc-1", + "title": "bash", + "kind": "execute" + }, + "options": [ + { "optionId": "allow_once", "name": "Allow", "kind": "allow_once" } + ] + }); + let event: ApprovalRequestEvent = serde_json::from_value(json).unwrap(); + assert_eq!(event.request_id, "req-1"); + assert_eq!(event.tool_call.unwrap().title.unwrap(), "bash"); + assert_eq!(event.options.unwrap().len(), 1); + } +} diff --git a/crates/aionui-ai-agent/src/manager/remote/agent.rs b/crates/aionui-ai-agent/src/manager/remote/agent.rs new file mode 100644 index 000000000..12dad5603 --- /dev/null +++ b/crates/aionui-ai-agent/src/manager/remote/agent.rs @@ -0,0 +1,450 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use crate::AgentError; +use aionui_common::{ + AgentKillReason, AgentType, Confirmation, ConversationStatus, ErrorChain, RemoteAgentStatus, TimestampMs, +}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tokio::sync::{Mutex, RwLock, broadcast}; +use tokio_tungstenite::tungstenite::Message; +use tracing::{debug, error, info, warn}; + +use crate::agent_runtime::AgentRuntime; +use crate::protocol::events::AgentStreamEvent; +use crate::protocol::send_error::AgentSendError; +use crate::types::SendMessageData; + +/// Internal mutable state for the Remote agent. +struct RemoteState { + session_key: Option, + confirmations: Vec, + has_messages: bool, + approval_memory: HashMap, + connection_status: RemoteAgentStatus, +} + +/// Configuration for connecting to a remote agent. +#[derive(Debug, Clone)] +pub struct RemoteAgentConfig { + pub remote_agent_id: String, + pub url: String, + pub auth_type: String, + pub auth_token: Option, + pub allow_insecure: bool, +} + +/// Manages a Remote Agent via WebSocket connection. +/// +/// Remote agents communicate over WebSocket, reusing the OpenClaw Gateway +/// connection protocol. The Rust implementation owns the WebSocket connection +/// directly (no CLI subprocess). +pub struct RemoteAgentManager { + runtime: AgentRuntime, + remote_config: RemoteAgentConfig, + state: RwLock, + /// WebSocket sink for sending messages, wrapped in Mutex for concurrency. + ws_sink: Mutex< + Option< + futures_util::stream::SplitSink< + tokio_tungstenite::WebSocketStream>, + Message, + >, + >, + >, + /// Handle to the WebSocket reader task. + _reader_handle: Mutex>>, +} + +impl RemoteAgentManager { + /// Create a new Remote agent by establishing a WebSocket connection. + pub async fn new( + conversation_id: String, + workspace: String, + remote_config: RemoteAgentConfig, + ) -> Result { + let runtime = AgentRuntime::new(conversation_id, workspace, 256); + + let manager = Self { + runtime, + remote_config, + state: RwLock::new(RemoteState { + session_key: None, + confirmations: Vec::new(), + has_messages: false, + approval_memory: HashMap::new(), + connection_status: RemoteAgentStatus::Unknown, + }), + ws_sink: Mutex::new(None), + _reader_handle: Mutex::new(None), + }; + + Ok(manager) + } + + /// Connect to the remote WebSocket endpoint and start the reader task. + pub async fn connect(self: &Arc) -> Result<(), AgentError> { + let url = &self.remote_config.url; + + let (ws_stream, _response) = tokio_tungstenite::connect_async(url).await.map_err(|e| { + error!(url = url, error = %ErrorChain(&e), "Failed to connect to remote agent"); + AgentError::Internal(format!("WebSocket connection failed: {e}")) + })?; + + info!( + conversation_id = %self.runtime.conversation_id(), + url = url, + "Connected to remote agent" + ); + + let (sink, stream) = ws_stream.split(); + + // Store the sink for sending messages + *self.ws_sink.lock().await = Some(sink); + + // Update connection status + { + let mut state = self.state.write().await; + state.connection_status = RemoteAgentStatus::Connected; + } + + // Start reader task + let this = Arc::clone(self); + let reader_handle = tokio::spawn(async move { + this.run_ws_reader(stream).await; + }); + + *self._reader_handle.lock().await = Some(reader_handle); + + Ok(()) + } + + /// Read messages from the WebSocket and process them. + async fn run_ws_reader( + self: Arc, + mut stream: futures_util::stream::SplitStream< + tokio_tungstenite::WebSocketStream>, + >, + ) { + while let Some(msg) = stream.next().await { + match msg { + Ok(Message::Text(text)) => { + self.runtime.bump_activity(); + match serde_json::from_str::(&text) { + Ok(raw_json) => self.handle_raw_event(raw_json).await, + Err(e) => { + debug!( + conversation_id = %self.runtime.conversation_id(), + error = %ErrorChain(&e), + "Non-JSON WebSocket message, skipping" + ); + } + } + } + Ok(Message::Close(_)) => { + debug!( + conversation_id = %self.runtime.conversation_id(), + "Remote WebSocket closed" + ); + break; + } + Err(e) => { + warn!( + conversation_id = %self.runtime.conversation_id(), + error = %ErrorChain(&e), + "WebSocket read error" + ); + break; + } + _ => {} // Ignore ping/pong/binary + } + } + + // Connection closed — update connection_status and ensure terminal agent status. + { + let mut state = self.state.write().await; + state.connection_status = RemoteAgentStatus::Error; + } + if self.runtime.status() == Some(ConversationStatus::Running) { + self.runtime.transition_to(ConversationStatus::Finished); + } + } + + async fn handle_raw_event(&self, raw: Value) { + let stream_event = match serde_json::from_value::(raw.clone()) { + Ok(event) => event, + Err(_) => { + debug!( + conversation_id = %self.runtime.conversation_id(), + "Unrecognized remote event, skipping" + ); + return; + } + }; + + self.update_state_from_event(&stream_event).await; + self.runtime.emit(stream_event); + } + + async fn update_state_from_event(&self, event: &AgentStreamEvent) { + match event { + AgentStreamEvent::Start(data) => { + self.runtime.transition_to(ConversationStatus::Running); + if let Some(ref sid) = data.session_id { + let mut state = self.state.write().await; + state.session_key = Some(sid.clone()); + } + } + AgentStreamEvent::Finish(data) => { + self.runtime.transition_to(ConversationStatus::Finished); + if let Some(ref sid) = data.session_id { + let mut state = self.state.write().await; + state.session_key = Some(sid.clone()); + } + } + AgentStreamEvent::Error(_) => { + self.runtime.transition_to(ConversationStatus::Finished); + } + AgentStreamEvent::AcpPermission(data) => { + if let Some(conf) = data.as_confirmation() { + let mut guard = self.state.write().await; + if let Some(existing) = guard.confirmations.iter_mut().find(|c| c.call_id == conf.call_id) { + *existing = conf; + } else { + guard.confirmations.push(conf); + } + } + } + _ => {} + } + } + + /// Send a JSON message over the WebSocket. + async fn ws_send(&self, payload: &Value) -> Result<(), AgentError> { + let text = serde_json::to_string(payload) + .map_err(|e| AgentError::Internal(format!("Failed to serialize WebSocket message: {e}")))?; + + let mut guard = self.ws_sink.lock().await; + let sink = guard + .as_mut() + .ok_or_else(|| AgentError::Internal("WebSocket not connected".into()))?; + + sink.send(Message::Text(text.into())).await.map_err(|e| { + error!( + conversation_id = %self.runtime.conversation_id(), + error = %ErrorChain(&e), + "Failed to send WebSocket message" + ); + AgentError::Internal(format!("WebSocket send failed: {e}")) + }) + } + + /// Get the connection status. + pub async fn connection_status(&self) -> RemoteAgentStatus { + self.state.read().await.connection_status + } +} + +use crate::shared_kernel::approval_key; + +#[async_trait::async_trait] +impl crate::agent_task::IAgentTask for RemoteAgentManager { + fn agent_type(&self) -> AgentType { + AgentType::Remote + } + + fn conversation_id(&self) -> &str { + self.runtime.conversation_id() + } + + fn workspace(&self) -> &str { + self.runtime.workspace() + } + + fn status(&self) -> Option { + self.runtime.status() + } + + fn last_activity_at(&self) -> TimestampMs { + self.runtime.last_activity_at() + } + + fn subscribe(&self) -> broadcast::Receiver { + self.runtime.subscribe() + } + + async fn send_message(&self, data: SendMessageData) -> Result<(), AgentSendError> { + self.runtime.bump_activity(); + + let is_first = { + let mut state = self.state.write().await; + let first = !state.has_messages; + state.has_messages = true; + first + }; + self.runtime.transition_to(ConversationStatus::Running); + + if is_first { + // First message: create new session via sessionsReset + let payload = json!({ + "type": "sessionsReset", + "data": { + "conversationId": self.runtime.conversation_id(), + "message": data.content, + "msgId": data.msg_id, + } + }); + match self.ws_send(&payload).await { + Ok(()) => Ok(()), + Err(err) => { + error!( + conversation_id = %self.runtime.conversation_id(), + error = %ErrorChain(&err), + "Remote send_message failed, emitting Error" + ); + let send_error = AgentSendError::from_agent_error(err); + self.runtime.emit_error_data(send_error.stream_error().clone()); + Err(send_error) + } + } + } else { + // Subsequent messages: try to resume session + let session_key = self.state.read().await.session_key.clone(); + let mut payload = json!({ + "type": "sendMessage", + "data": { + "message": data.content, + "msgId": data.msg_id, + } + }); + if let Some(ref key) = session_key { + payload["data"]["sessionKey"] = json!(key); + } + if !data.files.is_empty() { + payload["data"]["files"] = json!(data.files); + } + match self.ws_send(&payload).await { + Ok(()) => Ok(()), + Err(err) => { + error!( + conversation_id = %self.runtime.conversation_id(), + error = %ErrorChain(&err), + "Remote send_message failed, emitting Error" + ); + let send_error = AgentSendError::from_agent_error(err); + self.runtime.emit_error_data(send_error.stream_error().clone()); + Err(send_error) + } + } + } + } + + async fn cancel(&self) -> Result<(), AgentError> { + if self.ws_sink.lock().await.is_none() { + return Err(AgentError::Conflict( + "WebSocket not connected; nothing to cancel".into(), + )); + } + let payload = json!({ "type": "session/cancel", "data": {} }); + self.ws_send(&payload).await?; + + let mut state = self.state.write().await; + state.confirmations.clear(); + Ok(()) + } + + fn kill(&self, reason: Option) -> Result<(), AgentError> { + info!( + conversation_id = %self.runtime.conversation_id(), + ?reason, + "Killing Remote agent" + ); + + // Drop the WebSocket sink to close the connection. + // We can't move the Mutex into a spawned task, so we clear it inline + // using try_lock (non-blocking). If the lock is held, the connection + // will close when the holder drops it. + if let Ok(mut guard) = self.ws_sink.try_lock() { + *guard = None; + } + + Ok(()) + } +} + +impl RemoteAgentManager { + pub fn kill_and_wait( + &self, + reason: Option, + ) -> std::pin::Pin + Send>> { + let _ = crate::agent_task::IAgentTask::kill(self, reason); + Box::pin(std::future::ready(())) + } +} + +/// Remote-specific operations reached through `AgentInstance::Remote(..)`. +impl RemoteAgentManager { + pub fn confirm(&self, _msg_id: &str, call_id: &str, _data: Value, always_allow: bool) -> Result<(), AgentError> { + if let Ok(mut state) = self.state.try_write() { + if always_allow && let Some(conf) = state.confirmations.iter().find(|c| c.call_id == call_id) { + let key = approval_key(conf.action.as_deref(), conf.command_type.as_deref()); + state.approval_memory.insert(key, true); + } + state.confirmations.retain(|c| c.call_id != call_id); + } + + // WebSocket send for confirmation will be fully wired in Phase 6.15 integration + // via a command channel that avoids &self lifetime issues in spawned tasks. + warn!( + conversation_id = %self.runtime.conversation_id(), + call_id = call_id, + "Remote agent confirm: WebSocket send deferred to integration phase" + ); + + Ok(()) + } + + pub fn get_confirmations(&self) -> Vec { + self.state + .try_read() + .map(|g| g.confirmations.clone()) + .unwrap_or_default() + } + + pub fn check_approval(&self, action: &str, command_type: Option<&str>) -> bool { + self.state + .try_read() + .map(|g| { + let key = approval_key(Some(action), command_type); + g.approval_memory.get(&key).copied().unwrap_or(false) + }) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn approval_key_formats_correctly() { + assert_eq!(approval_key(Some("exec"), Some("curl")), "exec:curl"); + assert_eq!(approval_key(Some("exec"), None), "exec"); + assert_eq!(approval_key(None, None), ""); + } + + #[test] + fn remote_agent_config_clone() { + let config = RemoteAgentConfig { + remote_agent_id: "ra-1".into(), + url: "wss://example.com".into(), + auth_type: "bearer".into(), + auth_token: Some("token".into()), + allow_insecure: false, + }; + let cloned = config.clone(); + assert_eq!(cloned.remote_agent_id, "ra-1"); + assert_eq!(cloned.url, "wss://example.com"); + } +} diff --git a/crates/aionui-ai-agent/src/manager/remote/mod.rs b/crates/aionui-ai-agent/src/manager/remote/mod.rs new file mode 100644 index 000000000..b5d775d8d --- /dev/null +++ b/crates/aionui-ai-agent/src/manager/remote/mod.rs @@ -0,0 +1,3 @@ +pub mod agent; + +pub use agent::{RemoteAgentConfig, RemoteAgentManager}; diff --git a/crates/aionui-ai-agent/src/protocol/error.rs b/crates/aionui-ai-agent/src/protocol/error.rs index df742f5f6..a10633fc7 100644 --- a/crates/aionui-ai-agent/src/protocol/error.rs +++ b/crates/aionui-ai-agent/src/protocol/error.rs @@ -268,6 +268,22 @@ impl AcpError { ) } + /// Whether the error indicates the persisted `session_id` is no longer + /// recognised by the agent CLI. Treats both the explicit `SessionNotFound` + /// variant and `ResourceNotFound` errors whose resource URI equals the + /// stored session id as stale-session failures (some ACP backends emit + /// `-32002` with the session id as the missing resource after a restart). + pub fn is_session_not_found_like(&self, session_id: &str) -> bool { + match self { + AcpError::SessionNotFound { session_id: sid } => sid == session_id, + AcpError::ResourceNotFound { + resource: Some(resource), + .. + } => resource == session_id, + _ => false, + } + } + /// Convert an SDK [`Error`](SdkError) into an [`AcpError`]. /// /// Mapping is by [`ErrorCode`], never by message text. The single diff --git a/crates/aionui-ai-agent/src/session_context.rs b/crates/aionui-ai-agent/src/session_context.rs index c193bb0ef..e0281a864 100644 --- a/crates/aionui-ai-agent/src/session_context.rs +++ b/crates/aionui-ai-agent/src/session_context.rs @@ -1,4 +1,4 @@ -use aionui_api_types::{AcpBuildExtra, AionrsBuildExtra, TeamSessionBinding}; +use aionui_api_types::{AcpBuildExtra, AionrsBuildExtra, OpenClawBuildExtra, TeamSessionBinding}; use aionui_common::{AgentType, ProviderWithModel}; use crate::shared_kernel::PersistedSessionState; @@ -41,6 +41,7 @@ pub struct WorkspaceContext { pub enum AgentSessionKind { Acp(Box), Aionrs(Box), + OpenclawGateway(Box), } #[derive(Debug, Clone)] diff --git a/crates/aionui-ai-agent/tests/agent_types_integration.rs b/crates/aionui-ai-agent/tests/agent_types_integration.rs index b405afdfa..ac6de4bc8 100644 --- a/crates/aionui-ai-agent/tests/agent_types_integration.rs +++ b/crates/aionui-ai-agent/tests/agent_types_integration.rs @@ -149,7 +149,7 @@ async fn aionrs_agent_metadata() { fn agent_session_kind_is_limited_to_runnable_runtimes() { fn assert_runnable(kind: AgentSessionKind) { match kind { - AgentSessionKind::Acp(_) | AgentSessionKind::Aionrs(_) => {} + AgentSessionKind::Acp(_) | AgentSessionKind::Aionrs(_) | AgentSessionKind::OpenclawGateway(_) => {} } } diff --git a/crates/aionui-ai-agent/tests/factory_provider_integration.rs b/crates/aionui-ai-agent/tests/factory_provider_integration.rs index 6d13fc216..7cfffb84b 100644 --- a/crates/aionui-ai-agent/tests/factory_provider_integration.rs +++ b/crates/aionui-ai-agent/tests/factory_provider_integration.rs @@ -12,8 +12,9 @@ use aionui_ai_agent::types::BuildTaskOptions; use aionui_api_types::AionrsBuildExtra; use aionui_common::{AgentType, ProviderWithModel, encrypt_string}; use aionui_db::{ - CreateProviderParams, IAcpSessionRepository, IProviderRepository, SqliteAcpSessionRepository, - SqliteAgentMetadataRepository, SqliteProviderRepository, init_database_memory, + CreateProviderParams, IAcpSessionRepository, IProviderRepository, IRemoteAgentRepository, + SqliteAcpSessionRepository, SqliteAgentMetadataRepository, SqliteProviderRepository, SqliteRemoteAgentRepository, + init_database_memory, }; use aionui_realtime::BroadcastEventBus; @@ -60,13 +61,16 @@ async fn insert_test_provider(repo: &dyn IProviderRepository, id: &str, platform .unwrap(); } -fn make_factory( +async fn make_factory( provider_repo: Arc, agent_registry: Arc, acp_agent_service: Arc, ) -> aionui_ai_agent::task_manager::AgentFactory { let tmp = tempfile::TempDir::new().unwrap(); let skill_paths = Arc::new(aionui_extension::resolve_skill_paths(tmp.path(), tmp.path())); + let db = init_database_memory().await.unwrap(); + let remote_agent_repo: Arc = + Arc::new(SqliteRemoteAgentRepository::new(db.pool().clone())); build_agent_factory(AgentFactoryDeps { skill_manager: AcpSkillManager::new(skill_paths), provider_repo, @@ -78,6 +82,7 @@ fn make_factory( broadcaster: Arc::new(BroadcastEventBus::new(16)), backend_binary_path: Arc::new(PathBuf::from("/tmp/aionrs-test/aioncore")), mcp_server_repo: None, + remote_agent_repo, }) } @@ -113,7 +118,7 @@ fn make_aionrs_options( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn aionrs_factory_returns_error_for_missing_provider() { let (provider_repo, agent_registry, acp_agent_service) = setup().await; - let factory = make_factory(provider_repo, agent_registry, acp_agent_service); + let factory = make_factory(provider_repo, agent_registry, acp_agent_service).await; let options = make_aionrs_options( "conv-test-1", @@ -143,7 +148,7 @@ async fn aionrs_factory_returns_error_for_missing_provider() { async fn aionrs_factory_resolves_provider_from_db() { let (provider_repo, agent_registry, acp_agent_service) = setup().await; insert_test_provider(&*provider_repo, "prov-001", "openai").await; - let factory = make_factory(provider_repo, agent_registry, acp_agent_service); + let factory = make_factory(provider_repo, agent_registry, acp_agent_service).await; let options = make_aionrs_options( "conv-test-2", @@ -167,7 +172,7 @@ async fn aionrs_factory_resolves_provider_from_db() { async fn aionrs_factory_respects_use_model_override() { let (provider_repo, agent_registry, acp_agent_service) = setup().await; insert_test_provider(&*provider_repo, "prov-002", "openai").await; - let factory = make_factory(provider_repo, agent_registry, acp_agent_service); + let factory = make_factory(provider_repo, agent_registry, acp_agent_service).await; let options = make_aionrs_options( "conv-test-3", diff --git a/crates/aionui-api-types/src/agent_build_extra.rs b/crates/aionui-api-types/src/agent_build_extra.rs index ce7ab365b..42a3531d1 100644 --- a/crates/aionui-api-types/src/agent_build_extra.rs +++ b/crates/aionui-api-types/src/agent_build_extra.rs @@ -106,6 +106,47 @@ pub struct AionrsBuildExtra { pub user_id: Option, } +/// OpenClaw gateway configuration. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct OpenClawGatewayConfig { + pub host: Option, + pub port: Option, + pub token: Option, + pub password: Option, + #[serde(default)] + pub use_external_gateway: bool, + pub cli_path: Option, +} + +/// OpenClaw-specific fields extracted from `extra` in build task options. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct OpenClawBuildExtra { + #[serde(default)] + pub backend: Option, + #[serde(default)] + pub agent_name: Option, + #[serde(default)] + pub gateway: OpenClawGatewayConfig, + #[serde(default)] + pub skills: Vec, + #[serde(default)] + pub preset_assistant_id: Option, + #[serde(default)] + pub cron_job_id: Option, + #[serde(default, rename = "sessionKey")] + pub session_key: Option, + #[serde(default)] + pub remote_agent_id: Option, + #[serde(default)] + pub user_id: Option, +} + +/// Remote agent-specific fields extracted from `extra` in build task options. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteBuildExtra { + pub remote_agent_id: String, +} + fn default_aionrs_max_tokens() -> u32 { 8192 } diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index fbcd5b660..47de9b2d2 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -39,8 +39,8 @@ pub use acp::{ }; pub use acp_prompt_hook::AcpPromptHookWarningPayload; pub use agent_build_extra::{ - AcpBuildExtra, AcpModelInfo, AionrsBuildExtra, SessionMcpServer, SessionMcpTransport, - SlashCommandCompletionBehavior, SlashCommandItem, + AcpBuildExtra, AcpModelInfo, AionrsBuildExtra, OpenClawBuildExtra, OpenClawGatewayConfig, RemoteBuildExtra, + SessionMcpServer, SessionMcpTransport, SlashCommandCompletionBehavior, SlashCommandItem, }; pub use agent_discovery::{ AgentEnvEntry, AgentHandshake, AgentLogoEntry, AgentManagementRow, AgentManagementStatus, AgentMetadata, diff --git a/crates/aionui-app/src/commands/server.rs b/crates/aionui-app/src/commands/server.rs new file mode 100644 index 000000000..63d42b1c6 --- /dev/null +++ b/crates/aionui-app/src/commands/server.rs @@ -0,0 +1,89 @@ +//! `aioncore` (no subcommand): the main HTTP server. + +use std::process::ExitCode; +use std::time::Instant; + +use anyhow::Result; +use tokio::net::TcpListener; +use tracing::{info, warn}; + +use aionui_app::{AppServices, create_router}; + +use crate::bootstrap::ServerEnvironment; + +/// Start the HTTP server with fully constructed services. +pub async fn run_server(env: ServerEnvironment, services: AppServices) -> Result { + let boot = Instant::now(); + + let has_users = services.user_repo.has_users().await?; + if !has_users { + info!("No configured users detected — initial setup required via /api/auth/status"); + } + + let router = create_router(&services).await; + let addr = env.config.socket_addr(); + let listener = TcpListener::bind(&addr).await?; + let bound_addr = listener.local_addr()?; + info!( + elapsed_ms = boot.elapsed().as_millis(), + "Server listening on {bound_addr}" + ); + // Emit machine-readable port for the web-host launcher (M4+). + println!("AIONCORE_LISTENING {{\"port\":{}}}", bound_addr.port()); + + // Kick off the idle-ACP-agent reaper. `start_idle_scanner` returns + // immediately with a `JoinHandle`; the scanner task polls every 60 s + // and kills ACP agents whose `status == Finished` + last_activity + // exceeds the default 5-minute idle threshold. The watch channel + // propagates graceful-shutdown so the scanner exits on SIGINT/SIGTERM. + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + let idle_scanner_handle = + aionui_ai_agent::start_idle_scanner(services.worker_task_manager.clone(), shutdown_rx, None, None); + + axum::serve(listener, router) + .with_graceful_shutdown(async move { + shutdown_signal().await; + let _ = shutdown_tx.send(true); + }) + .await?; + + // Wait for the scanner to observe the shutdown watch value and + // return; at worst this blocks for the current 60 s tick. + if let Err(e) = idle_scanner_handle.await { + warn!(error = %e, "idle scanner join failed"); + } + + services.database.close().await; + info!("Server shut down gracefully"); + + // Prevent the log guard from being dropped before final log flush. + drop(env); + + Ok(ExitCode::SUCCESS) +} + +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c().await.expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + () = ctrl_c => { + info!("Received SIGINT, shutting down..."); + } + () = terminate => { + info!("Received SIGTERM, shutting down..."); + } + } +} diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index 468ee06bd..5d36b2e46 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -667,6 +667,7 @@ pub fn build_cron_state(services: &AppServices) -> CronRouterState { agent_metadata_repo, assistant_definition_repo, assistant_overlay_repo, + remote_agent_repo: Some(services.remote_agent_repo.clone()), scheduler, executor, emitter, diff --git a/crates/aionui-app/src/services.rs b/crates/aionui-app/src/services.rs index 9a8856714..9d7630a09 100644 --- a/crates/aionui-app/src/services.rs +++ b/crates/aionui-app/src/services.rs @@ -13,10 +13,10 @@ use aionui_common::OnConversationDelete; use aionui_conversation::{ConversationService, runtime_state::ConversationRuntimeStateService}; use aionui_db::{ Database, IAcpSessionRepository, IAgentMetadataRepository, IConversationRepository, IMcpServerRepository, - ISkillRepository, IUserRepository, SqliteAcpSessionRepository, SqliteAgentMetadataRepository, - SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, SqliteAssistantPreferenceRepository, - SqliteConversationRepository, SqliteMcpServerRepository, SqliteProviderRepository, SqliteSkillRepository, - SqliteUserRepository, + ISkillRepository, IRemoteAgentRepository, IUserRepository, SqliteAcpSessionRepository, + SqliteAgentMetadataRepository, SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, + SqliteAssistantPreferenceRepository, SqliteConversationRepository, SqliteMcpServerRepository, + SqliteProviderRepository, SqliteRemoteAgentRepository, SqliteSkillRepository, SqliteUserRepository, }; use aionui_realtime::{BroadcastEventBus, WebSocketManager}; @@ -51,6 +51,7 @@ pub struct AppServices { pub skill_paths: Arc, /// User skill metadata and import history repository. pub skill_repo: Arc, + pub remote_agent_repo: Arc, } impl AppServices { @@ -128,6 +129,8 @@ impl AppServices { let conversation_repo: Arc = Arc::new(SqliteConversationRepository::new(database.pool().clone())); let skill_repo: Arc = Arc::new(SqliteSkillRepository::new(database.pool().clone())); + let remote_agent_repo: Arc = + Arc::new(SqliteRemoteAgentRepository::new(database.pool().clone())); // Skill paths need app resource dir (for builtin rules) + data dir // (for user skills + materialized views). AcpSkillManager uses these @@ -159,6 +162,7 @@ impl AppServices { broadcaster: event_bus.clone(), backend_binary_path: backend_binary_path.clone(), mcp_server_repo: Some(mcp_server_repo), + remote_agent_repo: remote_agent_repo.clone(), }); // Agent factory is now wired. Future extension/custom agents @@ -202,6 +206,7 @@ impl AppServices { app_version, skill_paths, skill_repo, + remote_agent_repo, }) } } @@ -234,6 +239,7 @@ fn build_conversation_service(deps: ConversationServiceDeps<'_>) -> Conversation ) .with_runtime_state(deps.conversation_runtime_state); service.with_mcp_server_repo(Arc::new(SqliteMcpServerRepository::new(deps.database.pool().clone()))); + service.with_remote_agent_repo(Arc::new(SqliteRemoteAgentRepository::new(deps.database.pool().clone()))); service.with_assistant_definition_repo(Arc::new(SqliteAssistantDefinitionRepository::new( deps.database.pool().clone(), ))); diff --git a/crates/aionui-app/tests/conversation_e2e.rs b/crates/aionui-app/tests/conversation_e2e.rs index 19f48a505..26ffff39e 100644 --- a/crates/aionui-app/tests/conversation_e2e.rs +++ b/crates/aionui-app/tests/conversation_e2e.rs @@ -65,7 +65,7 @@ async fn t1_2_create_supported_agent_types_and_reject_legacy_types() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; - let types = ["acp", "aionrs"]; + let types = ["acp", "aionrs", "remote", "openclaw-gateway"]; for agent_type in types { let body = json!({ "type": agent_type, @@ -78,7 +78,7 @@ async fn t1_2_create_supported_agent_types_and_reject_legacy_types() { assert_eq!(json["data"]["type"], agent_type); } - for agent_type in ["openclaw-gateway", "nanobot", "remote", "gemini"] { + for agent_type in ["nanobot", "gemini"] { let body = json!({ "type": agent_type, "extra": {} diff --git a/crates/aionui-app/tests/shell_e2e.rs b/crates/aionui-app/tests/shell_e2e.rs index 507548d53..fdf7311b9 100644 --- a/crates/aionui-app/tests/shell_e2e.rs +++ b/crates/aionui-app/tests/shell_e2e.rs @@ -131,10 +131,14 @@ async fn sh4_show_item_in_folder_not_found() { let (mut app, services) = build_app_with_noop_opener().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + // Use a path that is guaranteed not to exist in this environment. + // `/nonexistent/path` happens to exist in some CI images, so a deeper, + // unique path avoids false positives. + let missing_path = format!("/nonexistent/aionui_shell_missing_{}", std::process::id()); let req = json_with_token( "POST", "/api/shell/show-item-in-folder", - json!({ "file_path": "/nonexistent/path" }), + json!({ "file_path": missing_path }), &token, &csrf, ); diff --git a/crates/aionui-channel/src/message_service.rs b/crates/aionui-channel/src/message_service.rs index 37b627fbf..2c3c41a22 100644 --- a/crates/aionui-channel/src/message_service.rs +++ b/crates/aionui-channel/src/message_service.rs @@ -383,7 +383,7 @@ fn parse_agent_type(s: &str) -> Result { } }; - if agent_type.is_deprecated_runtime() { + if !agent_type.supports_conversation_runtime() { return Err(ChannelError::InvalidConfig(DEPRECATED_AGENT_TYPE_MESSAGE.into())); } @@ -468,7 +468,7 @@ mod tests { #[test] fn parse_agent_type_rejects_deprecated_channel_runtime_types() { - for raw in ["openclaw-gateway", "nanobot", "remote", "gemini"] { + for raw in ["nanobot", "gemini"] { let err = parse_agent_type(raw).unwrap_err(); assert!(matches!(err, ChannelError::InvalidConfig(_))); } diff --git a/crates/aionui-common/src/enums.rs b/crates/aionui-common/src/enums.rs index 295c03973..9b9e08c87 100644 --- a/crates/aionui-common/src/enums.rs +++ b/crates/aionui-common/src/enums.rs @@ -52,7 +52,17 @@ impl AgentType { } pub fn supports_new_conversation(&self) -> bool { - matches!(self, AgentType::Acp | AgentType::Aionrs) + matches!( + self, + AgentType::Acp | AgentType::Aionrs | AgentType::OpenclawGateway | AgentType::Remote + ) + } + + pub fn supports_conversation_runtime(&self) -> bool { + matches!( + self, + AgentType::Acp | AgentType::Aionrs | AgentType::OpenclawGateway | AgentType::Remote + ) } pub fn is_deprecated_runtime(&self) -> bool { @@ -373,9 +383,9 @@ mod tests { assert!(!AgentType::Gemini.supports_new_conversation()); assert!(!AgentType::Codex.supports_new_conversation()); - assert!(!AgentType::OpenclawGateway.supports_new_conversation()); + assert!(AgentType::OpenclawGateway.supports_new_conversation()); assert!(!AgentType::Nanobot.supports_new_conversation()); - assert!(!AgentType::Remote.supports_new_conversation()); + assert!(AgentType::Remote.supports_new_conversation()); } #[test] @@ -385,9 +395,9 @@ mod tests { assert!(AgentType::Gemini.is_deprecated_runtime()); assert!(AgentType::Codex.is_deprecated_runtime()); - assert!(AgentType::OpenclawGateway.is_deprecated_runtime()); + assert!(!AgentType::OpenclawGateway.is_deprecated_runtime()); assert!(AgentType::Nanobot.is_deprecated_runtime()); - assert!(AgentType::Remote.is_deprecated_runtime()); + assert!(!AgentType::Remote.is_deprecated_runtime()); } #[test] diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 3ac0c0fe2..be3866359 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -29,9 +29,9 @@ use aionui_db::models::{ConversationRow, MessageRow}; use aionui_db::{ AgentBindingResolution, ConversationFilters, ConversationRowUpdate, CreateAcpSessionParams, IAcpSessionRepository, IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, - IAssistantPreferenceRepository, IConversationRepository, IMcpServerRepository, MessagePageCursor, - MessagePageDirection, MessagePageParams, SaveRuntimeStateParams, UpsertConversationAssistantSnapshotParams, - resolve_agent_binding_from_rows, + IAssistantPreferenceRepository, IConversationRepository, IMcpServerRepository, IRemoteAgentRepository, + MessagePageCursor, MessagePageDirection, MessagePageParams, SaveRuntimeStateParams, + UpsertConversationAssistantSnapshotParams, resolve_agent_binding_from_rows, }; use aionui_extension::AssistantRuleDispatcher; use aionui_mcp::{AcpMcpCapabilities, parse_acp_mcp_capabilities}; @@ -295,19 +295,20 @@ fn reject_deprecated_runtime_row(row: &ConversationRow) -> Result<(), Conversati return Ok(()); }; - if agent_type.is_deprecated_runtime() { - debug!( - conversation_id = %row.id, - agent_type = agent_type.serde_name(), - "Rejected deprecated runtime conversation" - ); - return Err(ConversationError::Archived { - id: row.id.clone(), - reason: LEGACY_CONVERSATION_ARCHIVED_MESSAGE.into(), - }); + if agent_type.supports_conversation_runtime() { + return Ok(()); } - Ok(()) + debug!( + conversation_id = %row.id, + agent_type = agent_type.serde_name(), + "Rejected deprecated runtime conversation" + ); + + Err(ConversationError::Archived { + id: row.id.clone(), + reason: LEGACY_CONVERSATION_ARCHIVED_MESSAGE.into(), + }) } #[derive(Clone)] @@ -335,6 +336,7 @@ pub struct ConversationService { conversation_repo: Arc, agent_metadata_repo: Arc, acp_session_repo: Arc, + remote_agent_repo: Arc>>>, } #[derive(Clone)] @@ -404,6 +406,7 @@ impl ConversationService { conversation_repo, agent_metadata_repo, acp_session_repo, + remote_agent_repo: Arc::new(RwLock::new(None)), } } @@ -434,6 +437,12 @@ impl ConversationService { } } + pub fn with_remote_agent_repo(&self, repo: Arc) { + if let Ok(mut guard) = self.remote_agent_repo.write() { + *guard = Some(repo); + } + } + pub fn with_assistant_definition_repo(&self, repo: Arc) { if let Ok(mut guard) = self.assistant_definition_repo.write() { *guard = Some(repo); @@ -2993,9 +3002,14 @@ impl ConversationService { row: &aionui_db::models::ConversationRow, ) -> Result { reject_deprecated_runtime_row(row)?; - SessionContextBuilder::new(&self.workspace_root, &self.agent_metadata_repo, &self.acp_session_repo) - .build_options(row) - .await + let mut builder = + SessionContextBuilder::new(&self.workspace_root, &self.agent_metadata_repo, &self.acp_session_repo); + if let Ok(guard) = self.remote_agent_repo.read() { + if let Some(repo) = guard.as_ref() { + builder = builder.with_remote_agent_repo(Arc::clone(repo)); + } + } + builder.build_options(row).await } pub async fn build_task_options_for_runtime( @@ -3004,7 +3018,14 @@ impl ConversationService { workspace_override: Option<&str>, ) -> Result { reject_deprecated_runtime_row(row)?; - SessionContextBuilder::new(&self.workspace_root, &self.agent_metadata_repo, &self.acp_session_repo) + let mut builder = + SessionContextBuilder::new(&self.workspace_root, &self.agent_metadata_repo, &self.acp_session_repo); + if let Ok(guard) = self.remote_agent_repo.read() { + if let Some(repo) = guard.as_ref() { + builder = builder.with_remote_agent_repo(Arc::clone(repo)); + } + } + builder .build_options_with_workspace_override(row, workspace_override) .await } @@ -3295,7 +3316,7 @@ fn context_backend_value(context: &AgentSessionContext) -> Option Option<&str> { match &options.context.kind { AgentSessionKind::Acp(ctx) => ctx.config.backend.as_deref(), - AgentSessionKind::Aionrs(_) => None, + AgentSessionKind::Aionrs(_) | AgentSessionKind::OpenclawGateway(_) => None, } } diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 75f64ce64..5776f767c 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -1307,13 +1307,7 @@ async fn create_returns_conversation_with_defaults() { async fn create_rejects_deprecated_agent_types_for_new_conversations() { let (svc, _broadcaster, _repo, _task_mgr) = make_service(); - for agent_type in [ - AgentType::Gemini, - AgentType::Codex, - AgentType::OpenclawGateway, - AgentType::Nanobot, - AgentType::Remote, - ] { + for agent_type in [AgentType::Gemini, AgentType::Codex, AgentType::Nanobot] { let mut req = make_create_req(); req.r#type = Some(agent_type); req.model = None; @@ -3008,13 +3002,7 @@ async fn send_message_rejects_legacy_runtime_conversations_as_archived() { let (svc, _broadcaster, repo, _task_mgr) = make_service(); let task_mgr: Arc = Arc::new(MockTaskManager::new()); - for agent_type in [ - AgentType::Gemini, - AgentType::Codex, - AgentType::OpenclawGateway, - AgentType::Nanobot, - AgentType::Remote, - ] { + for agent_type in [AgentType::Gemini, AgentType::Codex, AgentType::Nanobot] { let conv = insert_conversation_with_type(&repo, "user_1", agent_type).await; let err = svc @@ -5028,13 +5016,7 @@ async fn warmup_rejects_legacy_runtime_conversations_as_archived() { let (svc, _broadcaster, repo, _task_mgr) = make_service(); let task_mgr: Arc = Arc::new(MockTaskManager::new()); - for agent_type in [ - AgentType::Gemini, - AgentType::Codex, - AgentType::OpenclawGateway, - AgentType::Nanobot, - AgentType::Remote, - ] { + for agent_type in [AgentType::Gemini, AgentType::Codex, AgentType::Nanobot] { let conv = insert_conversation_with_type(&repo, "user_1", agent_type).await; let err = svc.warmup("user_1", &conv.id, &task_mgr).await.unwrap_err(); diff --git a/crates/aionui-conversation/src/session_context.rs b/crates/aionui-conversation/src/session_context.rs index a43541547..58af0e039 100644 --- a/crates/aionui-conversation/src/session_context.rs +++ b/crates/aionui-conversation/src/session_context.rs @@ -8,10 +8,10 @@ use aionui_ai_agent::session_context::{ }; use aionui_ai_agent::shared_kernel::{ConfigKey, ConfigValue, ModeId, ModelId, PersistedSessionState}; use aionui_ai_agent::types::BuildTaskOptions; -use aionui_api_types::{AcpBuildExtra, AionrsBuildExtra, TeamSessionBinding}; +use aionui_api_types::{AcpBuildExtra, AionrsBuildExtra, OpenClawBuildExtra, TeamSessionBinding}; use aionui_common::{AgentType, WorkspacePathValidationError, validate_workspace_path_availability}; use aionui_db::models::ConversationRow; -use aionui_db::{IAcpSessionRepository, IAgentMetadataRepository}; +use aionui_db::{IAcpSessionRepository, IAgentMetadataRepository, IRemoteAgentRepository}; use tracing::{debug, warn}; use crate::convert::string_to_enum; @@ -25,6 +25,7 @@ pub(crate) struct SessionContextBuilder<'a> { workspace_root: &'a Path, agent_metadata_repo: &'a Arc, acp_session_repo: &'a Arc, + remote_agent_repo: Option>, } impl<'a> SessionContextBuilder<'a> { @@ -37,9 +38,15 @@ impl<'a> SessionContextBuilder<'a> { workspace_root, agent_metadata_repo, acp_session_repo, + remote_agent_repo: None, } } + pub(crate) fn with_remote_agent_repo(mut self, repo: Arc) -> Self { + self.remote_agent_repo = Some(repo); + self + } + pub(crate) async fn build_options(&self, row: &ConversationRow) -> Result { Ok(BuildTaskOptions::new(self.build(row).await?)) } @@ -156,23 +163,78 @@ impl<'a> SessionContextBuilder<'a> { team: Option, ) -> Result { match agent_type { - AgentType::Acp => self - .build_acp_context(row, extra, team) - .await - .map(|context| AgentSessionKind::Acp(Box::new(context))), + AgentType::Acp => { + if let Some(config) = self.maybe_resolve_remote_agent_config(row, &extra).await? { + return Ok(AgentSessionKind::OpenclawGateway(Box::new(config))); + } + self.build_acp_context(row, extra, team) + .await + .map(|context| AgentSessionKind::Acp(Box::new(context))) + } AgentType::Aionrs => Ok(AgentSessionKind::Aionrs(Box::new(build_aionrs_context( row, extra, team, )))), - AgentType::Gemini - | AgentType::Codex - | AgentType::OpenclawGateway - | AgentType::Remote - | AgentType::Nanobot => { + AgentType::OpenclawGateway | AgentType::Remote => Ok(AgentSessionKind::OpenclawGateway(Box::new( + build_openclaw_context(row, extra), + ))), + AgentType::Gemini | AgentType::Codex | AgentType::Nanobot => { unreachable!("legacy agent types are rejected before build_kind") } } } + /// ACP conversations created from a remote agent carry the remote agent id + /// in `extra.agent_id` / `extra.custom_agent_id` / `extra.backend`. Route + /// those through the OpenClaw gateway runtime instead of the normal ACP + /// factory, which only knows about catalog-resolved agents. + async fn maybe_resolve_remote_agent_config( + &self, + row: &ConversationRow, + extra: &serde_json::Value, + ) -> Result, ConversationError> { + let repo = match self.remote_agent_repo.as_ref() { + Some(repo) => repo, + None => return Ok(None), + }; + + let candidates: Vec = [ + extra.get("agent_id").and_then(serde_json::Value::as_str), + extra.get("custom_agent_id").and_then(serde_json::Value::as_str), + extra.get("backend").and_then(serde_json::Value::as_str), + ] + .into_iter() + .flatten() + .filter(|value| !value.is_empty()) + .map(String::from) + .collect(); + + if candidates.is_empty() { + return Ok(None); + } + + for remote_agent_id in candidates { + match repo.find_by_id(&remote_agent_id).await { + Ok(Some(_)) => { + let mut config: OpenClawBuildExtra = + serde_json::from_value(extra.clone()).map_err(|e| ConversationError::BadRequest { + reason: format!("Invalid remote build options: {e}"), + })?; + config.remote_agent_id = Some(remote_agent_id); + config.user_id.get_or_insert_with(|| row.user_id.clone()); + return Ok(Some(config)); + } + Ok(None) => continue, + Err(e) => { + return Err(ConversationError::internal(format!( + "Failed to load remote agent config: {e}" + ))); + } + } + } + + Ok(None) + } + async fn build_acp_context( &self, row: &ConversationRow, @@ -355,6 +417,22 @@ fn build_aionrs_context( } } +fn build_openclaw_context(row: &ConversationRow, extra: serde_json::Value) -> OpenClawBuildExtra { + let mut config: OpenClawBuildExtra = match serde_json::from_value(extra.clone()) { + Ok(config) => config, + Err(err) => { + warn!( + conversation_id = %row.id, + error = %err, + "session_context: invalid openclaw extra; using defaults" + ); + OpenClawBuildExtra::default() + } + }; + config.user_id.get_or_insert_with(|| row.user_id.clone()); + config +} + fn apply_team_seed_to_acp_config(team: &Option, config: &mut AcpBuildExtra) { let Some(team) = team else { return; @@ -393,7 +471,7 @@ fn parse_extra(row: &ConversationRow) -> Result Result<(), ConversationError> { - if !agent_type.is_deprecated_runtime() { + if agent_type.supports_conversation_runtime() { return Ok(()); } @@ -939,12 +1017,7 @@ mod tests { for (agent_type, extra) in [ ("gemini", serde_json::json!({})), ("codex", serde_json::json!({ "workspace": "/tmp/aionui-codex-history" })), - ( - "openclaw-gateway", - serde_json::json!({ "gateway": { "use_external_gateway": true } }), - ), ("nanobot", serde_json::json!({})), - ("remote", serde_json::json!({})), ] { let row = row(agent_type, extra, None); let err = repos.builder().build(&row).await.unwrap_err(); diff --git a/crates/aionui-conversation/src/turn_orchestrator.rs b/crates/aionui-conversation/src/turn_orchestrator.rs index 0f704b029..d0f1434cb 100644 --- a/crates/aionui-conversation/src/turn_orchestrator.rs +++ b/crates/aionui-conversation/src/turn_orchestrator.rs @@ -21,7 +21,7 @@ use aionui_api_types::SendMessageRequest; fn acp_backend_from_build_options(options: &BuildTaskOptions) -> Option<&str> { match &options.context.kind { AgentSessionKind::Acp(ctx) => ctx.config.backend.as_deref(), - AgentSessionKind::Aionrs(_) => None, + AgentSessionKind::Aionrs(_) | AgentSessionKind::OpenclawGateway(_) => None, } } @@ -468,7 +468,7 @@ fn availability_agent_id(options: &BuildTaskOptions) -> Option { .as_deref() .filter(|value| !value.is_empty()) .map(str::to_owned), - AgentSessionKind::Aionrs(_) => None, + AgentSessionKind::Aionrs(_) | AgentSessionKind::OpenclawGateway(_) => None, } } diff --git a/crates/aionui-conversation/tests/conversation_crud.rs b/crates/aionui-conversation/tests/conversation_crud.rs index d72c2b8ba..9a5c181f8 100644 --- a/crates/aionui-conversation/tests/conversation_crud.rs +++ b/crates/aionui-conversation/tests/conversation_crud.rs @@ -164,7 +164,12 @@ async fn t1_1_create_with_defaults() { async fn t1_2_create_each_agent_type() { let (svc, _, _task_mgr) = setup().await; - let types = vec![("acp", AgentType::Acp), ("aionrs", AgentType::Aionrs)]; + let types = vec![ + ("acp", AgentType::Acp), + ("aionrs", AgentType::Aionrs), + ("openclaw-gateway", AgentType::OpenclawGateway), + ("remote", AgentType::Remote), + ]; for (type_str, expected_type) in types { let body = if type_str == "aionrs" { @@ -189,7 +194,7 @@ async fn t1_2_create_each_agent_type() { } } - for type_str in ["openclaw-gateway", "nanobot", "remote", "gemini"] { + for type_str in ["nanobot", "gemini"] { let req: CreateConversationRequest = serde_json::from_value(json!({ "type": type_str, "extra": {} @@ -685,11 +690,11 @@ async fn create_rejects_top_level_model_for_acp() { } #[tokio::test] -async fn create_rejects_deprecated_remote_runtime() { +async fn create_rejects_deprecated_nanobot_runtime() { let (svc, _, _task_mgr) = setup().await; let req: CreateConversationRequest = serde_json::from_value(json!({ - "type": "remote", + "type": "nanobot", "model": { "provider_id": "p1", "model": "m1" }, "extra": {} })) diff --git a/crates/aionui-cron/src/executor.rs b/crates/aionui-cron/src/executor.rs index d1de6b6db..5d5a6f31e 100644 --- a/crates/aionui-cron/src/executor.rs +++ b/crates/aionui-cron/src/executor.rs @@ -14,6 +14,11 @@ use aionui_conversation::{ use aionui_db::models::MessageRow; use aionui_db::{ConversationRowUpdate, IConversationRepository}; use aionui_realtime::EventBroadcaster; +use chrono::Local; +#[cfg(test)] +use tokio::sync::broadcast; +#[cfg(test)] +use tokio::time::timeout; use tracing::{error, info, warn}; use crate::artifacts::{broadcast_artifact, build_cron_trigger_artifact}; @@ -29,7 +34,7 @@ use crate::types::{CronJob, ExecutionMode}; pub const RETRY_INTERVAL_MS: u64 = 30_000; pub const MAX_RETRIES_DEFAULT: i64 = 3; const SYSTEM_DEFAULT_USER_ID: &str = "system_default_user"; -const DEPRECATED_AGENT_TYPE_MESSAGE: &str = "This agent type is no longer supported for new conversations."; +pub(crate) const DEPRECATED_AGENT_TYPE_MESSAGE: &str = "This agent type is no longer supported for new conversations."; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ExecutionResult { Success { conversation_id: String }, @@ -429,9 +434,16 @@ impl JobExecutor { let extra = build_conversation_extra(&self.agent_registry, job, saved_skill).await; let assistant = build_assistant_request(job); + let name = job + .conversation_title + .as_ref() + .filter(|t| !t.trim().is_empty()) + .map(|t| format!("{} · {}", t, Local::now().format("%b %-d"))) + .unwrap_or_else(|| format!("{} · {}", job.name, Local::now().format("%b %-d"))); + let req = CreateConversationRequest { r#type: if assistant.is_some() { None } else { Some(agent_type) }, - name: Some(job.name.clone()), + name: Some(name), model, assistant, source: None, @@ -793,6 +805,57 @@ impl JobExecutor { Ok(skills) } + #[allow(dead_code)] + async fn ensure_agent_session_mode( + &self, + job: &CronJob, + agent: &aionui_ai_agent::AgentInstance, + ) -> Result<(), CronError> { + let Some(desired_mode) = job + .agent_config + .as_ref() + .and_then(|config| config.mode.as_deref()) + .map(str::trim) + .filter(|mode| !mode.is_empty()) + else { + return Ok(()); + }; + + let current_mode = agent + .get_mode() + .await + .map_err(|e| CronError::Scheduler(format!("get session mode: {e}")))?; + + if current_mode.mode == desired_mode { + return Ok(()); + } + + match agent.set_mode(desired_mode).await { + Ok(()) => { + info!( + conversation_id = %agent.conversation_id(), + from_mode = %current_mode.mode, + to_mode = desired_mode, + initialized = current_mode.initialized, + "Applied cron session mode before execution" + ); + } + Err(e) if e.to_string().contains("not supported") => { + warn!( + conversation_id = %agent.conversation_id(), + desired_mode, + error = %e, + "Agent type does not support session mode switching, skipping" + ); + } + Err(e) => { + return Err(CronError::Scheduler(format!("set session mode to {desired_mode}: {e}"))); + } + } + + Ok(()) + } + async fn load_conversation_skill_names(&self, conversation_id: &str) -> Result, CronError> { let Some(row) = self .conversation_repo @@ -832,7 +895,7 @@ impl JobExecutor { /// 3. Fallback to [`AgentType::Acp`] to preserve the prior default. async fn parse_agent_type(registry: &AgentRegistry, agent_type_str: &str) -> Result { if let Ok(agent_type) = serde_json::from_value::(serde_json::Value::String(agent_type_str.to_owned())) { - if agent_type.is_deprecated_runtime() { + if !agent_type.supports_conversation_runtime() { return Err(CronError::InvalidAgentConfig(DEPRECATED_AGENT_TYPE_MESSAGE.into())); } return Ok(agent_type); @@ -924,6 +987,13 @@ async fn build_task_extra(registry: &AgentRegistry, job: &CronJob, skills: &[Str serde_json::Value::String(custom_agent_id.clone()), ); } + // Remote agent factory expects `remote_agent_id` in the extra. + if job.agent_type.eq_ignore_ascii_case("remote") || job.agent_type.eq_ignore_ascii_case("openclaw-gateway") { + extra.insert( + "remote_agent_id".to_owned(), + serde_json::Value::String(custom_agent_id.clone()), + ); + } } if let Some(mode) = &config.mode { extra.insert("session_mode".to_owned(), serde_json::Value::String(mode.clone())); @@ -934,6 +1004,14 @@ async fn build_task_extra(registry: &AgentRegistry, job: &CronJob, skills: &[Str } fn build_prompt(job: &CronJob, saved_skill: Option<&SavedSkillContext>) -> String { + // Remote/OpenClaw agents are external gateways: they forward the user + // message verbatim to a remote process. The scheduled-task wrapper used + // for LLM-style agents changes what the remote process sees, so cron + // output diverges from a manual chat click. Send the raw message instead. + if is_remote_like_job(job) { + return job.message.trim().to_owned(); + } + let schedule_desc = schedule_description_text(&job.schedule); match job.execution_mode { @@ -949,6 +1027,10 @@ fn build_prompt(job: &CronJob, saved_skill: Option<&SavedSkillContext>) -> Strin } fn build_assistant_request(job: &CronJob) -> Option { + if is_remote_like_job(job) { + return None; + } + let config = job.agent_config.as_ref()?; let assistant_id = config .assistant_id @@ -970,6 +1052,11 @@ fn build_assistant_request(job: &CronJob) -> Option bool { + let agent_type = job.agent_type.trim().to_lowercase(); + agent_type == "remote" || agent_type == "openclaw-gateway" +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SavedSkillContext { name: String, @@ -1008,6 +1095,25 @@ async fn build_conversation_extra( if !assistant_backed && !config.name.is_empty() { extra.insert("agent_name".to_owned(), serde_json::Value::String(config.name.clone())); } + if let Some(custom_agent_id) = &config.custom_agent_id { + extra.insert( + "custom_agent_id".to_owned(), + serde_json::Value::String(custom_agent_id.clone()), + ); + if config.is_preset.unwrap_or(false) { + extra.insert( + "preset_assistant_id".to_owned(), + serde_json::Value::String(custom_agent_id.clone()), + ); + } + // Remote agent factory expects `remote_agent_id` in the extra. + if job.agent_type.eq_ignore_ascii_case("remote") || job.agent_type.eq_ignore_ascii_case("openclaw-gateway") { + extra.insert( + "remote_agent_id".to_owned(), + serde_json::Value::String(custom_agent_id.clone()), + ); + } + } if let Some(mode) = &config.mode { extra.insert("session_mode".to_owned(), serde_json::Value::String(mode.clone())); } @@ -1323,6 +1429,46 @@ mod tests { assert!(prompt.contains("SKILL_SUGGEST.md")); } + #[test] + fn build_prompt_remote_agent_existing_mode_uses_raw_message() { + let job = CronJob { + agent_type: "remote".into(), + execution_mode: ExecutionMode::Existing, + ..sample_job() + }; + let prompt = build_prompt(&job, None); + assert_eq!(prompt, "do something"); + assert!(!prompt.contains("[Scheduled Task Execution]")); + } + + #[test] + fn build_prompt_openclaw_gateway_new_conv_uses_raw_message() { + let job = CronJob { + agent_type: "openclaw-gateway".into(), + execution_mode: ExecutionMode::NewConversation, + ..sample_job() + }; + let prompt = build_prompt(&job, None); + assert_eq!(prompt, "do something"); + assert!(!prompt.contains("[Scheduled Task Context]")); + assert!(!prompt.contains("SKILL_SUGGEST.md")); + } + + #[test] + fn build_prompt_acp_with_remote_backend_uses_raw_message() { + let job = CronJob { + agent_type: "acp".into(), + agent_config: Some(CronAgentConfig { + backend: "remote".into(), + ..sample_job().agent_config.unwrap() + }), + ..sample_job() + }; + let prompt = build_prompt(&job, None); + assert_eq!(prompt, "do something"); + assert!(!prompt.contains("[Scheduled Task Execution]")); + } + // -- registry helper ------------------------------------------------------ /// Build a registry backed by an in-memory DB seeded from the @@ -1348,7 +1494,7 @@ mod tests { #[tokio::test] async fn parse_agent_type_rejects_deprecated_runtime_types() { let registry = hydrated_registry().await; - for agent_type in ["openclaw-gateway", "nanobot", "remote", "gemini", "codex"] { + for agent_type in ["nanobot", "gemini", "codex"] { let err = parse_agent_type(®istry, agent_type).await.unwrap_err(); assert!(matches!(err, CronError::InvalidAgentConfig(_))); assert!( diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index 033e23bbe..f2cbc6db7 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -12,14 +12,14 @@ use aionui_common::{ }; use aionui_db::{ IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, ICronRepository, - UpdateCronJobParams, resolve_agent_binding_from_rows, + IRemoteAgentRepository, UpdateCronJobParams, resolve_agent_binding_from_rows, }; use tracing::{error, info, warn}; use crate::events::CronEventEmitter; use crate::error::CronError; -use crate::executor::{ExecutionResult, JobExecutor, RETRY_INTERVAL_MS}; +use crate::executor::{DEPRECATED_AGENT_TYPE_MESSAGE, ExecutionResult, JobExecutor, RETRY_INTERVAL_MS}; use crate::scheduler::{CronScheduler, compute_next_run, validate_schedule}; use crate::skill_file::{delete_skill_file, has_skill_file, write_raw_skill_file, write_skill_file}; use crate::types::{ @@ -45,6 +45,7 @@ pub struct CronService { agent_metadata_repo: Arc, assistant_definition_repo: Arc, assistant_overlay_repo: Arc, + remote_agent_repo: Option>, scheduler: Arc, executor: Arc, emitter: CronEventEmitter, @@ -56,6 +57,7 @@ pub struct CronServiceDeps { pub agent_metadata_repo: Arc, pub assistant_definition_repo: Arc, pub assistant_overlay_repo: Arc, + pub remote_agent_repo: Option>, pub scheduler: Arc, pub executor: Arc, pub emitter: CronEventEmitter, @@ -69,6 +71,7 @@ impl CronService { agent_metadata_repo: deps.agent_metadata_repo, assistant_definition_repo: deps.assistant_definition_repo, assistant_overlay_repo: deps.assistant_overlay_repo, + remote_agent_repo: deps.remote_agent_repo, scheduler: deps.scheduler, executor: deps.executor, emitter: deps.emitter, @@ -96,6 +99,7 @@ impl CronService { Some(agent_type) => agent_type, None => self.resolve_new_job_agent_type(req.agent_config.as_ref()).await?, }; + reject_deprecated_new_conversation_agent_type(&resolved_agent_type)?; validate_aionrs_agent_config(&resolved_agent_type, req.agent_config.as_ref())?; let execution_mode = parse_execution_mode(req.execution_mode.as_deref())?; @@ -161,7 +165,7 @@ impl CronService { .await? .ok_or_else(|| CronError::JobNotFound(job_id.to_owned()))?; let mut job = cron_job_from_row(existing_row)?; - job.agent_type = self.resolve_job_agent_type(&job).await?; + self.resolve_job_agent_type(&mut job).await?; let original_execution_mode = job.execution_mode; if let Some(name) = &req.name { @@ -195,6 +199,7 @@ impl CronService { if let Some(config_dto) = &req.agent_config { let config_dto = sanitize_agent_config_dto(config_dto.clone()); job.agent_type = self.resolve_new_job_agent_type(Some(&config_dto)).await?; + reject_deprecated_new_conversation_agent_type(&job.agent_type)?; validate_aionrs_agent_config(&job.agent_type, Some(&config_dto))?; job.agent_config = Some(self.build_cron_agent_config(&job.agent_type, config_dto, None).await?); } @@ -241,7 +246,7 @@ impl CronService { .await? .ok_or_else(|| CronError::JobNotFound(job_id.to_owned()))?; let mut job = cron_job_from_row(row)?; - job.agent_type = self.resolve_job_agent_type(&job).await?; + self.resolve_job_agent_type(&mut job).await?; Ok(job) } @@ -255,7 +260,7 @@ impl CronService { let mut jobs = Vec::with_capacity(rows.len()); for row in rows { let mut job = cron_job_from_row(row)?; - job.agent_type = self.resolve_job_agent_type(&job).await?; + self.resolve_job_agent_type(&mut job).await?; jobs.push(job); } Ok(jobs) @@ -329,8 +334,8 @@ impl CronService { return; } }; - match self.resolve_job_agent_type(&job).await { - Ok(agent_type) => job.agent_type = agent_type, + match self.resolve_job_agent_type(&mut job).await { + Ok(()) => {} Err(e) => { error!(job_id, error = %e, "Tick: failed to resolve cron assistant runtime"); return; @@ -394,7 +399,7 @@ impl CronService { .await? .ok_or_else(|| CronError::JobNotFound(job_id.to_owned()))?; let mut job = cron_job_from_row(row)?; - job.agent_type = self.resolve_job_agent_type(&job).await?; + self.resolve_job_agent_type(&mut job).await?; let prepared = self.executor.prepare_run_now(&job).await?; let conversation_id = prepared.conversation_id.clone(); let service = self.clone(); @@ -486,23 +491,41 @@ impl CronService { self.resolve_agent_type_for_assistant_id(assistant_id).await } - async fn resolve_job_agent_type(&self, job: &CronJob) -> Result { + async fn resolve_job_agent_type(&self, job: &mut CronJob) -> Result<(), CronError> { if !job.agent_type.trim().is_empty() { - return Ok(job.agent_type.clone()); + return Ok(()); } - let Some(assistant_id) = job + if let Some(assistant_id) = job .agent_config .as_ref() .and_then(|config| config.assistant_id.as_deref().or(config.custom_agent_id.as_deref())) .filter(|value| !value.trim().is_empty()) - else { - return Err(CronError::InvalidAgentConfig( - "assistant_id is required for cron jobs".into(), - )); - }; + { + job.agent_type = self.resolve_agent_type_for_assistant_id(assistant_id).await?; + return Ok(()); + } - self.resolve_agent_type_for_assistant_id(assistant_id).await + // Legacy remote-agent cron jobs persisted only the display name in + // `agent_config.name` and dropped the `agent_type` column upstream. + // Recover them by matching the name against the remote_agents table. + if let Some(config) = job.agent_config.as_mut() { + let agent_name = config.name.trim(); + if !agent_name.is_empty() { + if let Some(repo) = self.remote_agent_repo.as_ref() { + let rows = repo.list().await?; + if let Some(remote_agent) = rows.into_iter().find(|row| row.name == agent_name) { + config.custom_agent_id = Some(remote_agent.id.clone()); + job.agent_type = remote_agent_type_from_protocol(&remote_agent.protocol); + return Ok(()); + } + } + } + } + + Err(CronError::InvalidAgentConfig( + "assistant_id is required for cron jobs".into(), + )) } async fn resolve_agent_type_for_assistant_id(&self, assistant_id: &str) -> Result { @@ -619,7 +642,8 @@ impl CronService { self.emitter.emit_job_executed(job_id, "skipped", None); } ExecutionResult::Error { message } => { - self.update_job_after_error(job_id, &message).await; + self.update_job_after_error(job_id, &job.conversation_id, &message) + .await; self.reschedule_after_execution(&job).await; self.emitter.emit_job_executed(job_id, "error", Some(&message)); } @@ -633,7 +657,7 @@ impl CronService { self.emitter.emit_job_executed(job_id, "ok", None); } ExecutionResult::Error { message } => { - self.update_job_after_error(job_id, &message).await; + self.update_job_after_error(job_id, "", &message).await; self.emitter.emit_job_executed(job_id, "error", Some(&message)); } ExecutionResult::Retrying { attempt } => { @@ -681,8 +705,13 @@ impl CronService { // "existing" job is materialized (lazy binding). Subsequent runs then // reuse the same conversation, matching the UX where the job is the // continuation anchor. - let needs_conversation_bind = - existing_row.conversation_id.trim().is_empty() && !conversation_id.trim().is_empty(); + // + // For "new_conversation" jobs a fresh conversation is created on every + // run, so we always overwrite the stored id with the latest one. This + // keeps the cron job UI pointing at the most recent execution. + let is_new_conversation = existing_row.execution_mode == "new_conversation"; + let needs_conversation_bind = (is_new_conversation && !conversation_id.trim().is_empty()) + || (existing_row.conversation_id.trim().is_empty() && !conversation_id.trim().is_empty()); let params = UpdateCronJobParams { last_run_at: Some(Some(now)), last_status: Some(Some("ok".into())), @@ -712,9 +741,9 @@ impl CronService { } } - async fn update_job_after_error(&self, job_id: &str, message: &str) { - let run_count = match self.repo.get_by_id(job_id).await { - Ok(Some(r)) => r.run_count, + async fn update_job_after_error(&self, job_id: &str, conversation_id: &str, message: &str) { + let existing_row = match self.repo.get_by_id(job_id).await { + Ok(Some(r)) => r, Ok(None) => return, Err(e) => { error!(job_id, error = %e, "Failed to read job for run_count"); @@ -722,12 +751,22 @@ impl CronService { } }; let now = now_ms(); + // New-conversation jobs spawn a fresh conversation before executing; + // if the run fails we still want the cron job row to point at that + // conversation so the user can inspect what went wrong. + let is_new_conversation = existing_row.execution_mode == "new_conversation"; + let conversation_id_update = if is_new_conversation && !conversation_id.trim().is_empty() { + Some(conversation_id.to_owned()) + } else { + None + }; let params = UpdateCronJobParams { last_run_at: Some(Some(now)), last_status: Some(Some("error".into())), last_error: Some(Some(message.to_owned())), retry_count: Some(0), - run_count: Some(run_count + 1), + run_count: Some(existing_row.run_count + 1), + conversation_id: conversation_id_update, ..Default::default() }; if let Err(e) = self.repo.update(job_id, ¶ms).await { @@ -901,17 +940,26 @@ impl CronService { } }; - for row in &jobs { + // Only cascade-delete existing-mode jobs. NewConversation jobs create a + // fresh conversation on every run and merely store the last created + // conversation_id; deleting that conversation should not delete the + // cron job itself (the user would lose their scheduled task). + let existing_jobs: Vec<_> = jobs + .into_iter() + .filter(|row| row.execution_mode != "new_conversation") + .collect(); + + for row in &existing_jobs { self.scheduler.cancel_job(&row.id); self.emitter.emit_job_removed(&row.id); } if let Err(e) = self.repo.delete_by_conversation(conversation_id).await { error!(conversation_id, error = %e, "Failed to cascade-delete cron jobs"); - } else if !jobs.is_empty() { + } else if !existing_jobs.is_empty() { info!( conversation_id, - count = jobs.len(), + count = existing_jobs.len(), "Cascade-deleted cron jobs for conversation" ); } @@ -1382,6 +1430,14 @@ fn runtime_agent_type_for_backend(backend: &str) -> &'static str { if backend == "aionrs" { "aionrs" } else { "acp" } } +fn remote_agent_type_from_protocol(protocol: &str) -> String { + if protocol.eq_ignore_ascii_case("openclaw") { + "openclaw-gateway".to_owned() + } else { + "remote".to_owned() + } +} + fn normalize_model( model: Option, runtime_agent_type: &str, @@ -1430,6 +1486,14 @@ fn validate_aionrs_agent_config( Ok(()) } +fn reject_deprecated_new_conversation_agent_type(agent_type: &str) -> Result<(), CronError> { + let parsed = serde_json::from_value::(serde_json::Value::String(agent_type.to_owned())).ok(); + if parsed.is_some_and(|agent_type| !agent_type.supports_conversation_runtime()) { + return Err(CronError::InvalidAgentConfig(DEPRECATED_AGENT_TYPE_MESSAGE.into())); + } + Ok(()) +} + fn parse_execution_mode(mode: Option<&str>) -> Result { match mode { None | Some("existing") => Ok(ExecutionMode::Existing), diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index be10faa63..126b2edd6 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -734,6 +734,7 @@ async fn setup_with_conv_repo() -> ( agent_metadata_repo, assistant_definition_repo: assistant_definition_repo.clone(), assistant_overlay_repo: assistant_overlay_repo.clone(), + remote_agent_repo: None, scheduler, executor, emitter, @@ -830,6 +831,7 @@ async fn setup_with_assistant_repos() -> ( agent_metadata_repo, assistant_definition_repo: assistant_definition_repo.clone(), assistant_overlay_repo: assistant_overlay_repo.clone(), + remote_agent_repo: None, scheduler, executor, emitter, @@ -1039,24 +1041,19 @@ async fn create_job_strips_legacy_agent_ids_when_assistant_id_present() { } #[tokio::test] -async fn create_job_derives_assistant_runtime_without_backend_hint() { +async fn create_job_rejects_deprecated_agent_types() { let (svc, _, _) = setup().await; - let mut req = make_create_req("Stale Backend Hint", every_60s()); - req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { - name: "Stale Backend Hint".into(), - cli_path: None, - assistant_id: Some("assistant-default".into()), - mode: Some("default".into()), - model_id: Some("claude-sonnet-4".into()), - model: None, - config_options: None, - workspace: None, - }); - - let job = svc.add_job(req).await.unwrap(); + for agent_type in ["nanobot", "gemini", "codex"] { + let mut req = make_create_req(&format!("Deprecated {agent_type}"), every_60s()); + req.agent_type = agent_type.to_owned(); - assert_eq!(job.agent_type, "acp"); + let err = svc.add_job(req).await.unwrap_err(); + assert!( + matches!(err, aionui_cron::error::CronError::InvalidAgentConfig(_)), + "expected InvalidAgentConfig for {agent_type}, got {err:?}" + ); + } } #[tokio::test] diff --git a/crates/aionui-shell/src/shell.rs b/crates/aionui-shell/src/shell.rs index 244e9598d..d8aa4cf99 100644 --- a/crates/aionui-shell/src/shell.rs +++ b/crates/aionui-shell/src/shell.rs @@ -264,7 +264,7 @@ mod tests { #[test] fn validate_path_exists_fails_for_nonexistent() { - let result = validate_path_exists("/nonexistent/path"); + let result = validate_path_exists("/nonexistent/nonexistent_path"); assert!(matches!(result, Err(ShellError::FileNotFound(_)))); } @@ -357,7 +357,7 @@ mod tests { #[tokio::test] async fn show_item_in_folder_fails_for_missing_path() { let svc = ShellService::new(Arc::new(NoopSystemOpener)); - let result = svc.show_item_in_folder("/nonexistent/path").await; + let result = svc.show_item_in_folder("/nonexistent/nonexistent_path").await; assert!(matches!(result, Err(ShellError::FileNotFound(_)))); } diff --git a/crates/aionui-shell/tests/shell_integration.rs b/crates/aionui-shell/tests/shell_integration.rs index 3eae8a1c8..d6330264c 100644 --- a/crates/aionui-shell/tests/shell_integration.rs +++ b/crates/aionui-shell/tests/shell_integration.rs @@ -27,7 +27,10 @@ async fn sh2_open_file_not_found() { // --------------------------------------------------------------------------- #[tokio::test] async fn sh4_show_item_in_folder_not_found() { - let err = service().show_item_in_folder("/nonexistent/path").await.unwrap_err(); + let err = service() + .show_item_in_folder("/nonexistent/nonexistent_path") + .await + .unwrap_err(); let msg = err.to_string(); assert!(msg.contains("not found"), "expected 'not found', got: {msg}"); } From fc616c33f57b739c299a25ede12a157f21087c7d Mon Sep 17 00:00:00 2001 From: wwellzero Date: Wed, 1 Jul 2026 20:02:39 +0800 Subject: [PATCH 2/3] fix(agent): restore GET /api/agents route for frontend agent selectors --- crates/aionui-ai-agent/src/routes/agent.rs | 13 ++++++++++++- crates/aionui-ai-agent/src/services/agent.rs | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/aionui-ai-agent/src/routes/agent.rs b/crates/aionui-ai-agent/src/routes/agent.rs index 98db9537a..c180bdfac 100644 --- a/crates/aionui-ai-agent/src/routes/agent.rs +++ b/crates/aionui-ai-agent/src/routes/agent.rs @@ -4,8 +4,9 @@ //! //! Endpoints: //! +//! - `GET /api/agents` — list available agents (frontend selector) //! - `GET /api/agents/management` — list diagnostics-first agent rows -//! - `POST /api/agents/refresh` — refresh agent list (e.g. after new agent is added to the system) +//! - `POST /api/agents/refresh` — refresh agent list (e.g. after new agent is added to the system) //! - `POST /api/agents/custom/try-connect` — test custom agent configuration (e.g. ACP connection) use axum::Router; @@ -26,6 +27,7 @@ use crate::routes::state::AgentRouterState; pub fn agent_routes(state: AgentRouterState) -> Router { Router::new() + .route("/api/agents", get(list_agents)) .route("/api/agents/logos", get(list_agent_logos)) .route("/api/agents/management", get(list_management_agents)) .route("/api/agents/refresh", post(refresh_agents)) @@ -42,6 +44,15 @@ pub fn agent_routes(state: AgentRouterState) -> Router { .with_state(state) } +async fn list_agents( + State(state): State, + Extension(_user): Extension, +) -> Result>>, ApiError> { + Ok(Json(ApiResponse::ok( + state.service.list_agents().await.map_err(agent_error_to_api_error)?, + ))) +} + async fn refresh_agents( State(state): State, Extension(_user): Extension, diff --git a/crates/aionui-ai-agent/src/services/agent.rs b/crates/aionui-ai-agent/src/services/agent.rs index c65342776..473e6944e 100644 --- a/crates/aionui-ai-agent/src/services/agent.rs +++ b/crates/aionui-ai-agent/src/services/agent.rs @@ -76,6 +76,20 @@ impl AgentService { // Agent operations impl AgentService { + /// Legacy `/api/agents` listing used by the AionUi frontend. + /// + /// Returns the same `AgentMetadata` shape as `refresh_agents` without + /// re-probing availability, so guid/cron/team selectors load instantly. + pub async fn list_agents(&self) -> Result, AgentError> { + Ok(self + .registry + .list_all() + .await + .into_iter() + .filter(|agent| agent.agent_type.supports_new_conversation()) + .collect()) + } + pub async fn refresh_agents(&self) -> Result, AgentError> { self.registry.refresh_availability().await; Ok(self From 04f40321be5f33be26def2ab73a4157cc020fd6a Mon Sep 17 00:00:00 2001 From: wwellzero Date: Sat, 4 Jul 2026 17:02:58 +0800 Subject: [PATCH 3/3] fix(openclaw): reconnect closed websocket and restart event relay for follow-up messages --- .../aionui-ai-agent/src/factory/openclaw.rs | 6 +- .../manager/openclaw/agent/confirmations.rs | 3 +- .../src/manager/openclaw/agent/mod.rs | 124 ++++++++++++++++-- .../src/manager/openclaw/connection.rs | 1 + 4 files changed, 119 insertions(+), 15 deletions(-) diff --git a/crates/aionui-ai-agent/src/factory/openclaw.rs b/crates/aionui-ai-agent/src/factory/openclaw.rs index 032e6d716..f59c3ad33 100644 --- a/crates/aionui-ai-agent/src/factory/openclaw.rs +++ b/crates/aionui-ai-agent/src/factory/openclaw.rs @@ -74,7 +74,11 @@ pub(super) async fn build( deps.data_dir.clone(), ) .await?; - let arc = Arc::new(agent); + let mut arc = Arc::new(agent); + let weak = Arc::downgrade(&arc); + if let Some(manager) = Arc::get_mut(&mut arc) { + manager.set_weak_self(weak).await; + } arc.start_event_relay(); Ok(AgentInstance::OpenClaw(arc)) } diff --git a/crates/aionui-ai-agent/src/manager/openclaw/agent/confirmations.rs b/crates/aionui-ai-agent/src/manager/openclaw/agent/confirmations.rs index 216bbc8e3..43b44ab6b 100644 --- a/crates/aionui-ai-agent/src/manager/openclaw/agent/confirmations.rs +++ b/crates/aionui-ai-agent/src/manager/openclaw/agent/confirmations.rs @@ -31,7 +31,8 @@ impl OpenClawAgentManager { "requestId": call_id, "optionId": option_id, }); - if let Err(e) = connection.request::("exec.approval.respond", params).await { + let conn = connection.read().await.clone(); + if let Err(e) = conn.request::("exec.approval.respond", params).await { warn!(error = %e, "Failed to send OpenClaw approval response"); } }); diff --git a/crates/aionui-ai-agent/src/manager/openclaw/agent/mod.rs b/crates/aionui-ai-agent/src/manager/openclaw/agent/mod.rs index 52334b32a..719f2a34e 100644 --- a/crates/aionui-ai-agent/src/manager/openclaw/agent/mod.rs +++ b/crates/aionui-ai-agent/src/manager/openclaw/agent/mod.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; use crate::AgentError; @@ -48,9 +48,13 @@ pub struct OpenClawAgentManager { runtime: AgentRuntime, config: OpenClawBuildExtra, gateway_process: Option>, - pub(super) connection: Arc, + ws_url: String, + auth: Option, + connection: Arc>>, pub(super) state: Arc>, text_state: Mutex, + event_relay_handle: StdMutex>>, + weak_self: Mutex>>, } impl OpenClawAgentManager { @@ -136,7 +140,7 @@ impl OpenClawAgentManager { None }; - let (connection, hello) = OpenClawConnection::connect(&ws_url, auth, &identity) + let (connection, hello) = OpenClawConnection::connect(&ws_url, auth.clone(), &identity) .await .inspect_err(|e| { error!( @@ -178,7 +182,9 @@ impl OpenClawAgentManager { runtime, config, gateway_process, - connection: Arc::clone(&connection), + ws_url, + auth, + connection: Arc::new(RwLock::new(connection)), state: Arc::new(RwLock::new(OpenClawState { session_key: resume_session_key, confirmations: Vec::new(), @@ -186,6 +192,8 @@ impl OpenClawAgentManager { approval_memory: HashMap::new(), })), text_state: Mutex::new(TextFallbackState::new()), + event_relay_handle: StdMutex::new(None), + weak_self: Mutex::new(None), }; Ok(manager) @@ -193,13 +201,91 @@ impl OpenClawAgentManager { pub fn start_event_relay(self: &Arc) { let this = Arc::clone(self); - tokio::spawn(async move { + let handle = tokio::spawn(async move { this.run_event_relay().await; }); + if let Ok(mut guard) = self.event_relay_handle.lock() { + *guard = Some(handle); + } + } + + pub async fn set_weak_self( + &self, + weak: std::sync::Weak, + ) { + *self.weak_self.lock().await = Some(weak); + } + + async fn self_arc(&self) -> Option> { + self.weak_self.lock().await.as_ref()?.upgrade() + } + + async fn current_connection(&self) -> Arc { + self.connection.read().await.clone() + } + + /// Reconnect to the OpenClaw gateway when the WebSocket has been closed. + /// Replaces the cached connection and restarts the event relay so follow-up + /// user messages can keep working after the gateway drops the socket. + async fn reconnect(&self) -> Result<(), AgentError> { + info!( + conversation_id = %self.runtime.conversation_id(), + "OpenClaw connection closed; reconnecting" + ); + + let identity = load_or_create_identity(None)?; + let (connection, hello) = OpenClawConnection::connect( + &self.ws_url, + self.auth.clone(), + &identity, + ) + .await + .inspect_err(|e| { + error!( + conversation_id = %self.runtime.conversation_id(), + url = %self.ws_url, + error = %ErrorChain(e), + "Failed to reconnect to OpenClaw gateway" + ); + })?; + + if let Some(ref auth_info) = hello.auth + && let Some(ref device_token) = auth_info.device_token + { + super::device_auth_store::store_device_auth_token( + &identity.device_id, + auth_info.role.as_deref().unwrap_or("operator"), + device_token, + auth_info.scopes.as_deref().unwrap_or(&[]), + ); + } + + { + let mut guard = self.connection.write().await; + *guard = connection; + } + + if let Ok(mut guard) = self.event_relay_handle.lock() { + if let Some(handle) = guard.take() { + handle.abort(); + } + } + + if let Some(arc_self) = self.self_arc().await { + arc_self.start_event_relay(); + } + + info!( + conversation_id = %self.runtime.conversation_id(), + url = %self.ws_url, + "Reconnected to OpenClaw gateway" + ); + + Ok(()) } async fn run_event_relay(self: Arc) { - let mut event_rx = self.connection.subscribe_events().await; + let mut event_rx = self.current_connection().await.subscribe_events().await; loop { match event_rx.recv().await { @@ -278,7 +364,14 @@ impl OpenClawAgentManager { } } - async fn do_send_message(&self, is_first: bool, data: SendMessageData) -> Result<(), AgentError> { + async fn do_send_message(&self, + is_first: bool, + data: SendMessageData, + ) -> Result<(), AgentError> { + if !self.current_connection().await.is_connected() { + self.reconnect().await?; + } + if is_first { self.resolve_session().await?; } @@ -302,7 +395,8 @@ impl OpenClawAgentManager { }, }; - self.connection + self.current_connection() + .await .request::("chat.send", serde_json::to_value(params).unwrap_or_default()) .await?; @@ -316,7 +410,8 @@ impl OpenClawAgentManager { if let Some(ref key) = resume_key { match self - .connection + .current_connection() + .await .request::( "sessions.resolve", serde_json::to_value(SessionsResolveParams { key: key.clone() }).unwrap_or_default(), @@ -344,7 +439,8 @@ impl OpenClawAgentManager { } let resp: SessionsResetResponse = self - .connection + .current_connection() + .await .request( "sessions.reset", serde_json::to_value(SessionsResetParams { @@ -376,7 +472,7 @@ impl OpenClawAgentManager { "gatewayHost": host, "gatewayPort": port, "conversationId": self.runtime.conversation_id(), - "isConnected": self.connection.is_connected(), + "isConnected": self.current_connection().await.is_connected(), "hasActiveSession": state.session_key.is_some(), "sessionKey": state.session_key, }) @@ -449,7 +545,8 @@ impl crate::agent_task::IAgentTask for OpenClawAgentManager { run_id: None, }; let _ = self - .connection + .current_connection() + .await .request::("chat.abort", serde_json::to_value(params).unwrap_or_default()) .await; } @@ -489,7 +586,8 @@ impl crate::agent_task::IAgentTask for OpenClawAgentManager { let connection = Arc::clone(&self.connection); tokio::spawn(async move { - connection.close().await; + let conn = connection.read().await.clone(); + conn.close().await; }); if let Some(ref process) = self.gateway_process { diff --git a/crates/aionui-ai-agent/src/manager/openclaw/connection.rs b/crates/aionui-ai-agent/src/manager/openclaw/connection.rs index 64e10d1f5..fdcab139a 100644 --- a/crates/aionui-ai-agent/src/manager/openclaw/connection.rs +++ b/crates/aionui-ai-agent/src/manager/openclaw/connection.rs @@ -33,6 +33,7 @@ const DEFAULT_TICK_INTERVAL_MS: u64 = 30_000; type PendingSender = oneshot::Sender>; +#[derive(Clone)] pub struct AuthConfig { pub token: Option, pub password: Option,