From 66b487adb27edcb8e25a3ad4c851f7bf3d6bb71e Mon Sep 17 00:00:00 2001 From: zk <> Date: Mon, 15 Jun 2026 17:57:32 +0800 Subject: [PATCH 001/135] feat(agent): add connection testing and bare assistant projection --- crates/aionui-ai-agent/src/factory/acp.rs | 9 + crates/aionui-ai-agent/src/lib.rs | 1 + .../src/manager/acp/codex_sandbox.rs | 9 + .../src/manager/acp/mode_normalize.rs | 9 + .../src/protocol/cli_detect.rs | 58 +++ crates/aionui-ai-agent/src/registry.rs | 109 ++++- crates/aionui-ai-agent/src/registry_tests.rs | 27 ++ crates/aionui-ai-agent/src/routes/agent.rs | 35 +- crates/aionui-ai-agent/src/services/agent.rs | 22 +- .../src/services/availability/mod.rs | 393 ++++++++++++++++++ crates/aionui-ai-agent/src/services/mod.rs | 2 + .../tests/agent_availability_integration.rs | 115 +++++ .../aionui-api-types/src/agent_discovery.rs | 117 +++++- crates/aionui-api-types/src/assistant.rs | 19 + crates/aionui-api-types/src/lib.rs | 5 +- crates/aionui-app/src/router/state.rs | 24 +- crates/aionui-app/tests/acp_e2e.rs | 173 ++++++++ crates/aionui-app/tests/assistants_e2e.rs | 1 + crates/aionui-assistant/src/agent_catalog.rs | 8 + crates/aionui-assistant/src/lib.rs | 2 + crates/aionui-assistant/src/service.rs | 377 ++++++++++++++++- crates/aionui-conversation/src/service.rs | 54 ++- .../aionui-conversation/src/service_test.rs | 83 +++- .../src/turn_orchestrator.rs | 63 ++- .../013_agent_connection_snapshot.sql | 14 + crates/aionui-db/src/lib.rs | 6 +- crates/aionui-db/src/models/agent_metadata.rs | 24 ++ crates/aionui-db/src/models/mod.rs | 4 +- .../src/repository/agent_metadata.rs | 12 +- .../src/repository/sqlite_agent_metadata.rs | 102 ++++- .../tests/session_service_integration.rs | 10 +- 31 files changed, 1845 insertions(+), 42 deletions(-) create mode 100644 crates/aionui-ai-agent/src/services/availability/mod.rs create mode 100644 crates/aionui-ai-agent/tests/agent_availability_integration.rs create mode 100644 crates/aionui-assistant/src/agent_catalog.rs create mode 100644 crates/aionui-db/migrations/013_agent_connection_snapshot.sql diff --git a/crates/aionui-ai-agent/src/factory/acp.rs b/crates/aionui-ai-agent/src/factory/acp.rs index 6ab397256..f74a4b84c 100644 --- a/crates/aionui-ai-agent/src/factory/acp.rs +++ b/crates/aionui-ai-agent/src/factory/acp.rs @@ -677,6 +677,15 @@ mod tests { yolo_id: None, sort_order: 0, team_capable: false, + last_check_status: None, + last_check_kind: None, + last_check_error_code: None, + last_check_error_message: None, + last_check_guidance: None, + last_check_latency_ms: None, + last_check_at: None, + last_success_at: None, + last_failure_at: None, handshake: aionui_api_types::AgentHandshake::default(), }; diff --git a/crates/aionui-ai-agent/src/lib.rs b/crates/aionui-ai-agent/src/lib.rs index c049d4835..c16324ab5 100644 --- a/crates/aionui-ai-agent/src/lib.rs +++ b/crates/aionui-ai-agent/src/lib.rs @@ -39,6 +39,7 @@ pub use protocol::events::AgentStreamEvent; pub use protocol::send_error::AgentSendError; pub use registry::{AgentRegistry, UnavailableReason}; pub use routes::{AgentRouterState, RemoteAgentRouterState, agent_routes, remote_agent_routes}; +pub use services::AgentAvailabilityFeedbackPort; pub use services::AgentService; pub use services::RemoteAgentService; pub use session_context::{ diff --git a/crates/aionui-ai-agent/src/manager/acp/codex_sandbox.rs b/crates/aionui-ai-agent/src/manager/acp/codex_sandbox.rs index 58af7dbbc..068010acb 100644 --- a/crates/aionui-ai-agent/src/manager/acp/codex_sandbox.rs +++ b/crates/aionui-ai-agent/src/manager/acp/codex_sandbox.rs @@ -236,6 +236,15 @@ mod tests { yolo_id: Some("full-access".into()), sort_order: 3110, team_capable: true, + last_check_status: None, + last_check_kind: None, + last_check_error_code: None, + last_check_error_message: None, + last_check_guidance: None, + last_check_latency_ms: None, + last_check_at: None, + last_success_at: None, + last_failure_at: None, handshake: aionui_api_types::AgentHandshake::default(), } } diff --git a/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs b/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs index cb46753ec..a62b3c538 100644 --- a/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs +++ b/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs @@ -71,6 +71,15 @@ mod tests { yolo_id: yolo_id.map(ToOwned::to_owned), sort_order: 3130, team_capable: false, + last_check_status: None, + last_check_kind: None, + last_check_error_code: None, + last_check_error_message: None, + last_check_guidance: None, + last_check_latency_ms: None, + last_check_at: None, + last_success_at: None, + last_failure_at: None, handshake: AgentHandshake::default(), } } diff --git a/crates/aionui-ai-agent/src/protocol/cli_detect.rs b/crates/aionui-ai-agent/src/protocol/cli_detect.rs index 4bc93d377..a4644e65e 100644 --- a/crates/aionui-ai-agent/src/protocol/cli_detect.rs +++ b/crates/aionui-ai-agent/src/protocol/cli_detect.rs @@ -35,6 +35,64 @@ pub(crate) async fn health_check(registry: &Arc, backend: &str) - } fn probe_command(meta: &AgentMetadata) -> Option { + if let Some(path) = meta.resolved_command.as_ref() { + return Some(path.to_string_lossy().into_owned()); + } let cmd = meta.command.as_deref()?; resolve_command_path(cmd).map(|p| p.to_string_lossy().into_owned()) } + +#[cfg(test)] +mod tests { + use super::*; + use aionui_api_types::{ + AgentHandshake, AgentSnapshotCheckKind, AgentSnapshotCheckStatus, AgentSource, AgentSourceInfo, BehaviorPolicy, + }; + use aionui_common::AgentType; + use std::path::PathBuf; + + fn metadata_with_resolved_command() -> AgentMetadata { + AgentMetadata { + id: "agent-codex".into(), + icon: None, + name: "Codex CLI".into(), + name_i18n: None, + description: None, + description_i18n: None, + backend: Some("codex".into()), + agent_type: AgentType::Acp, + agent_source: AgentSource::Builtin, + agent_source_info: AgentSourceInfo { + binary_name: Some("codex".into()), + ..Default::default() + }, + enabled: true, + available: true, + command: None, + resolved_command: Some(PathBuf::from("codex-acp")), + args: vec![], + env: vec![], + native_skills_dirs: None, + behavior_policy: BehaviorPolicy::default(), + yolo_id: None, + sort_order: 0, + team_capable: false, + last_check_status: Some(AgentSnapshotCheckStatus::Available), + last_check_kind: Some(AgentSnapshotCheckKind::Startup), + last_check_error_code: None, + last_check_error_message: None, + last_check_guidance: None, + last_check_latency_ms: None, + last_check_at: None, + last_success_at: None, + last_failure_at: None, + handshake: AgentHandshake::default(), + } + } + + #[test] + fn probe_command_uses_hydrated_resolved_command_when_spawn_command_is_empty() { + let meta = metadata_with_resolved_command(); + assert_eq!(probe_command(&meta), Some("codex-acp".into())); + } +} diff --git a/crates/aionui-ai-agent/src/registry.rs b/crates/aionui-ai-agent/src/registry.rs index 5835a5bc1..81a98f9c4 100644 --- a/crates/aionui-ai-agent/src/registry.rs +++ b/crates/aionui-ai-agent/src/registry.rs @@ -17,7 +17,10 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -use aionui_api_types::{AgentEnvEntry, AgentHandshake, AgentMetadata, AgentSource, AgentSourceInfo, BehaviorPolicy}; +use aionui_api_types::{ + AgentEnvEntry, AgentHandshake, AgentManagementRow, AgentManagementStatus, AgentMetadata, AgentSnapshotCheckKind, + AgentSnapshotCheckStatus, AgentSource, AgentSourceInfo, BehaviorPolicy, +}; use aionui_common::AgentType; use aionui_db::{AgentMetadataRow, IAgentMetadataRepository, UpdateAgentHandshakeParams}; use aionui_runtime::{ @@ -265,6 +268,55 @@ impl AgentRegistry { rows } + /// Management read model for settings surfaces that need to show + /// official/custom rows even when unavailable. + pub async fn list_management_rows(&self) -> Vec { + let mut rows: Vec = self + .by_id + .read() + .await + .values() + .cloned() + .map(|meta| { + let status = derive_management_status(&meta); + AgentManagementRow { + id: meta.id, + icon: meta.icon, + name: meta.name, + name_i18n: meta.name_i18n, + description: meta.description, + description_i18n: meta.description_i18n, + backend: meta.backend, + agent_type: meta.agent_type, + agent_source: meta.agent_source, + agent_source_info: meta.agent_source_info, + enabled: meta.enabled, + installed: meta.available, + command: meta.command, + args: meta.args, + env: meta.env, + native_skills_dirs: meta.native_skills_dirs, + behavior_policy: meta.behavior_policy, + yolo_id: meta.yolo_id, + sort_order: meta.sort_order, + team_capable: meta.team_capable, + status, + last_check_status: meta.last_check_status, + last_check_kind: meta.last_check_kind, + last_check_error_code: meta.last_check_error_code, + last_check_error_message: meta.last_check_error_message, + last_check_guidance: meta.last_check_guidance, + last_check_latency_ms: meta.last_check_latency_ms, + last_check_at: meta.last_check_at, + last_success_at: meta.last_success_at, + last_failure_at: meta.last_failure_at, + } + }) + .collect(); + rows.sort_by(|a, b| a.sort_order.cmp(&b.sort_order).then_with(|| a.name.cmp(&b.name))); + rows + } + /// Like [`Self::list_all_including_hidden`] but pairs every row /// with a freshly-computed availability reason so callers (the /// `doctor` command, diagnostic UIs) can explain *why* a row is @@ -304,12 +356,13 @@ impl AgentRegistry { } } -/// A catalog row is visible to callers when the user has it enabled -/// and the spawn command was resolved at hydrate/refresh time. The -/// second check is what keeps uninstalled CLIs (e.g. `cursor` when -/// only `claude` is on PATH) off the pill bar. +/// A catalog row is visible to conversation callers when the user has +/// it enabled, the spawn command was resolved at hydrate/refresh time, +/// and the latest known availability snapshot does not already mark it +/// unavailable. This keeps both uninstalled CLIs and rows that most +/// recently failed ACP/session admission out of `/api/agents`. fn is_visible(meta: &AgentMetadata) -> bool { - meta.enabled && meta.available + meta.enabled && matches!(derive_management_status(meta), AgentManagementStatus::Available) } /// Turn a DB row into the public `AgentMetadata`, probing the command @@ -363,6 +416,15 @@ fn decode_row(row: AgentMetadataRow) -> Option<(AgentMetadata, Option Option { serde_json::from_value(Value::String(raw.to_owned())).ok() } +fn parse_last_check_status(raw: Option<&str>) -> Option { + raw.and_then(|value| match value { + "available" => Some(AgentSnapshotCheckStatus::Available), + "unavailable" => Some(AgentSnapshotCheckStatus::Unavailable), + _ => { + warn!(value, "agent_metadata: unknown last_check_status"); + None + } + }) +} + +fn parse_last_check_kind(raw: Option<&str>) -> Option { + raw.and_then(|value| match value { + "startup" => Some(AgentSnapshotCheckKind::Startup), + "scheduled" => Some(AgentSnapshotCheckKind::Scheduled), + "manual" => Some(AgentSnapshotCheckKind::Manual), + "session" => Some(AgentSnapshotCheckKind::Session), + _ => { + warn!(value, "agent_metadata: unknown last_check_kind"); + None + } + }) +} + +fn derive_management_status(meta: &AgentMetadata) -> AgentManagementStatus { + if !meta.available { + return AgentManagementStatus::Missing; + } + + match meta.last_check_status { + Some(AgentSnapshotCheckStatus::Unavailable) => AgentManagementStatus::Unavailable, + _ => AgentManagementStatus::Available, + } +} + fn decode_json_field(raw: Option<&str>, field: &str) -> Option { raw.and_then(|s| match serde_json::from_str(s) { Ok(v) => Some(v), diff --git a/crates/aionui-ai-agent/src/registry_tests.rs b/crates/aionui-ai-agent/src/registry_tests.rs index 25dbabb89..c383a83de 100644 --- a/crates/aionui-ai-agent/src/registry_tests.rs +++ b/crates/aionui-ai-agent/src/registry_tests.rs @@ -28,6 +28,15 @@ fn probe_resolved_command_accepts_bare_npx_when_managed_runtime_is_supported() { yolo_id: None, sort_order: 0, team_capable: false, + last_check_status: None, + last_check_kind: None, + last_check_error_code: None, + last_check_error_message: None, + last_check_guidance: None, + last_check_latency_ms: None, + last_check_at: None, + last_success_at: None, + last_failure_at: None, handshake: AgentHandshake::default(), }; @@ -68,6 +77,15 @@ fn probe_resolved_command_requires_primary_binary_for_builtin_managed_claude() { yolo_id: None, sort_order: 0, team_capable: false, + last_check_status: None, + last_check_kind: None, + last_check_error_code: None, + last_check_error_message: None, + last_check_guidance: None, + last_check_latency_ms: None, + last_check_at: None, + last_success_at: None, + last_failure_at: None, handshake: AgentHandshake::default(), }; @@ -111,6 +129,15 @@ fn probe_resolved_command_requires_primary_binary_for_builtin_managed_codex() { yolo_id: None, sort_order: 0, team_capable: false, + last_check_status: None, + last_check_kind: None, + last_check_error_code: None, + last_check_error_message: None, + last_check_guidance: None, + last_check_latency_ms: None, + last_check_at: None, + last_success_at: None, + last_failure_at: None, handshake: AgentHandshake::default(), }; diff --git a/crates/aionui-ai-agent/src/routes/agent.rs b/crates/aionui-ai-agent/src/routes/agent.rs index 926d366ec..e0407a758 100644 --- a/crates/aionui-ai-agent/src/routes/agent.rs +++ b/crates/aionui-ai-agent/src/routes/agent.rs @@ -14,9 +14,9 @@ use axum::extract::{Extension, Json, Path, State}; use axum::routing::{get, patch, post, put}; use aionui_api_types::{ - AcpHealthCheckRequest, AcpHealthCheckResponse, AgentMetadata, ApiResponse, CustomAgentUpsertRequest, - DeleteCustomAgentResponse, ProviderHealthCheckRequest, ProviderHealthCheckResponse, SetEnabledRequest, - TryConnectCustomAgentRequest, TryConnectCustomAgentResponse, + AcpHealthCheckRequest, AcpHealthCheckResponse, AgentManagementRow, AgentMetadata, ApiResponse, + CustomAgentUpsertRequest, DeleteCustomAgentResponse, ProviderHealthCheckRequest, ProviderHealthCheckResponse, + SetEnabledRequest, TryConnectCustomAgentRequest, TryConnectCustomAgentResponse, }; use aionui_auth::CurrentUser; use aionui_common::ApiError; @@ -27,8 +27,10 @@ use crate::routes::state::AgentRouterState; pub fn agent_routes(state: AgentRouterState) -> Router { Router::new() .route("/api/agents", get(list_agents)) + .route("/api/agents/management", get(list_management_agents)) .route("/api/agents/refresh", post(refresh_agents)) .route("/api/agents/health-check", post(health_check)) + .route("/api/agents/{id}/health-check", post(health_check_by_id)) .route("/api/agents/provider-health-check", post(provider_health_check)) .route("/api/agents/{id}/enabled", patch(set_agent_enabled)) .route("/api/agents/custom", post(create_custom)) @@ -55,6 +57,19 @@ async fn refresh_agents( ))) } +async fn list_management_agents( + State(state): State, + Extension(_user): Extension, +) -> Result>>, ApiError> { + Ok(Json(ApiResponse::ok( + state + .service + .list_management_agents() + .await + .map_err(agent_error_to_api_error)?, + ))) +} + async fn health_check( State(state): State, Extension(_user): Extension, @@ -70,6 +85,20 @@ async fn health_check( ))) } +async fn health_check_by_id( + State(state): State, + Extension(_user): Extension, + Path(id): Path, +) -> Result>, ApiError> { + Ok(Json(ApiResponse::ok( + state + .service + .health_check_agent_by_id(&id) + .await + .map_err(agent_error_to_api_error)?, + ))) +} + async fn provider_health_check( 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 677053564..0922c6d7c 100644 --- a/crates/aionui-ai-agent/src/services/agent.rs +++ b/crates/aionui-ai-agent/src/services/agent.rs @@ -16,12 +16,13 @@ use std::path::PathBuf; use std::sync::Arc; use aionui_api_types::{ - AcpHealthCheckRequest, AcpHealthCheckResponse, AgentMetadata, ProviderHealthCheckRequest, + AcpHealthCheckRequest, AcpHealthCheckResponse, AgentManagementRow, AgentMetadata, ProviderHealthCheckRequest, ProviderHealthCheckResponse, }; use aionui_db::IProviderRepository; use aionui_realtime::EventBroadcaster; +use super::availability::{AgentAvailabilityFeedbackPort, AgentAvailabilityService}; use super::provider_health::ProviderHealthCheckService; use crate::error::AgentError; use crate::registry::AgentRegistry; @@ -31,6 +32,7 @@ pub struct AgentService { broadcaster: Arc, data_dir: PathBuf, provider_health: ProviderHealthCheckService, + availability: AgentAvailabilityService, } impl AgentService { @@ -42,11 +44,13 @@ impl AgentService { data_dir: PathBuf, ) -> Arc { let provider_health = ProviderHealthCheckService::new(provider_repo, encryption_key, data_dir.clone()); + let availability = AgentAvailabilityService::new(registry.clone(), data_dir.clone()); Arc::new(Self { registry, broadcaster, data_dir, provider_health, + availability, }) } @@ -65,6 +69,14 @@ impl AgentService { pub(crate) fn broadcaster(&self) -> &Arc { &self.broadcaster } + + pub fn start_background_scheduler(&self) { + self.availability.start_background_scheduler(); + } + + pub fn availability_feedback_port(&self) -> Arc { + Arc::new(self.availability.clone()) + } } // Agent operations @@ -94,6 +106,14 @@ impl AgentService { Ok(crate::protocol::cli_detect::health_check(&self.registry, &req.backend).await) } + pub async fn list_management_agents(&self) -> Result, AgentError> { + Ok(self.availability.list_management_rows().await) + } + + pub async fn health_check_agent_by_id(&self, id: &str) -> Result { + self.availability.run_manual_health_check(id).await + } + pub async fn provider_health_check( &self, req: ProviderHealthCheckRequest, diff --git a/crates/aionui-ai-agent/src/services/availability/mod.rs b/crates/aionui-ai-agent/src/services/availability/mod.rs new file mode 100644 index 000000000..9fb8774ab --- /dev/null +++ b/crates/aionui-ai-agent/src/services/availability/mod.rs @@ -0,0 +1,393 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::time::Instant; + +use aionui_api_types::{ + AgentManagementRow, AgentMetadata, AgentSnapshotCheckKind, AgentSnapshotCheckStatus, TryConnectCustomAgentResponse, +}; +use aionui_common::now_ms; +use aionui_db::UpdateAgentAvailabilitySnapshotParams; +use tokio::time::{Duration, sleep}; + +use crate::error::AgentError; +use crate::protocol::{cli_detect, custom_agent_probe}; +use crate::registry::AgentRegistry; + +const DEFAULT_STARTUP_DELAY: Duration = Duration::from_secs(15); +const DEFAULT_SCHEDULED_INTERVAL: Duration = Duration::from_secs(300); + +#[async_trait::async_trait] +pub trait AgentAvailabilityFeedbackPort: Send + Sync { + async fn record_session_failure(&self, agent_id: &str, code: &str, message: &str) -> Result<(), AgentError>; +} + +struct AvailabilitySnapshot { + status: &'static str, + kind: &'static str, + error_code: Option, + error_message: Option, + latency_ms: i64, + checked_at: i64, +} + +#[derive(Clone)] +pub struct AgentAvailabilityService { + registry: Arc, + data_dir: PathBuf, + scheduler_started: Arc, + startup_delay: Duration, + scheduled_interval: Duration, +} + +impl AgentAvailabilityService { + pub fn new(registry: Arc, data_dir: PathBuf) -> Self { + Self { + registry, + data_dir, + scheduler_started: Arc::new(AtomicBool::new(false)), + startup_delay: DEFAULT_STARTUP_DELAY, + scheduled_interval: DEFAULT_SCHEDULED_INTERVAL, + } + } + + pub fn start_background_scheduler(&self) { + if self + .scheduler_started + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return; + } + + let service = self.clone(); + tokio::spawn(async move { + sleep(service.startup_delay).await; + loop { + if let Err(error) = service.run_scheduled_probe_pass().await { + tracing::warn!(error = %error, "agent availability scheduled probe pass failed"); + } + sleep(service.scheduled_interval).await; + } + }); + } + + pub async fn list_management_rows(&self) -> Vec { + self.registry.refresh_availability().await; + self.registry.list_management_rows().await + } + + pub async fn run_manual_health_check(&self, id: &str) -> Result { + self.registry.invalidate_and_rehydrate().await?; + let meta = self + .registry + .get(id) + .await + .ok_or_else(|| AgentError::not_found(format!("Agent '{id}' not found")))?; + + if !meta.available { + return self + .management_row_by_id(id) + .await + .ok_or_else(|| AgentError::not_found(format!("Agent '{id}' not found"))); + } + + let snapshot = run_probe(&self.registry, &meta, &self.data_dir, AgentSnapshotCheckKind::Manual).await; + self.persist_snapshot(id, &snapshot).await?; + self.management_row_by_id(id) + .await + .ok_or_else(|| AgentError::not_found(format!("Agent '{id}' not found"))) + } + + pub async fn record_session_failure(&self, agent_id: &str, code: &str, message: &str) -> Result<(), AgentError> { + let checked_at = now_ms(); + let snapshot = AvailabilitySnapshot { + status: "unavailable", + kind: "session", + error_code: Some(code.to_owned()), + error_message: Some(message.to_owned()), + latency_ms: 0, + checked_at, + }; + self.persist_snapshot(agent_id, &snapshot).await + } + + async fn management_row_by_id(&self, id: &str) -> Option { + self.registry + .list_management_rows() + .await + .into_iter() + .find(|row| row.id == id) + } + + async fn run_scheduled_probe_pass(&self) -> Result<(), AgentError> { + self.registry.invalidate_and_rehydrate().await?; + let rows = self.registry.list_all_including_hidden().await; + for meta in rows + .into_iter() + .filter(|item| item.enabled && item.available && item.agent_type.supports_new_conversation()) + { + let snapshot = run_probe(&self.registry, &meta, &self.data_dir, AgentSnapshotCheckKind::Scheduled).await; + self.persist_snapshot(&meta.id, &snapshot).await?; + } + Ok(()) + } + + async fn persist_snapshot(&self, id: &str, snapshot: &AvailabilitySnapshot) -> Result<(), AgentError> { + let existing = self + .registry + .repo_handle() + .get(id) + .await + .map_err(|error| AgentError::internal(format!("repo.get: {error}")))? + .ok_or_else(|| AgentError::not_found(format!("Agent '{id}' not found")))?; + + let params = UpdateAgentAvailabilitySnapshotParams { + last_check_status: Some(snapshot.status), + last_check_kind: Some(snapshot.kind), + last_check_error_code: snapshot.error_code.as_deref(), + last_check_error_message: snapshot.error_message.as_deref(), + last_check_guidance: None, + last_check_latency_ms: Some(snapshot.latency_ms), + last_check_at: Some(snapshot.checked_at), + last_success_at: if snapshot.status == "available" { + Some(snapshot.checked_at) + } else { + existing.last_success_at + }, + last_failure_at: if snapshot.status == "unavailable" { + Some(snapshot.checked_at) + } else { + existing.last_failure_at + }, + }; + self.registry + .repo_handle() + .update_availability_snapshot(id, ¶ms) + .await + .map_err(|error| AgentError::internal(format!("repo.update_availability_snapshot: {error}")))?; + self.registry.invalidate_and_rehydrate().await?; + Ok(()) + } +} + +async fn run_probe( + registry: &Arc, + meta: &AgentMetadata, + data_dir: &std::path::Path, + kind: AgentSnapshotCheckKind, +) -> AvailabilitySnapshot { + let started_at = now_ms(); + let start = Instant::now(); + + let (status, error_code, error_message) = if let Some(command) = meta.command.as_deref() { + let env: HashMap = meta + .env + .iter() + .map(|entry| (entry.name.clone(), entry.value.clone())) + .collect(); + match custom_agent_probe::try_connect_custom_agent(command, &meta.args, &env, data_dir, None).await { + TryConnectCustomAgentResponse::Success => (AgentSnapshotCheckStatus::Available, None, None), + TryConnectCustomAgentResponse::FailCli { error } => ( + AgentSnapshotCheckStatus::Unavailable, + Some("command_not_found".to_owned()), + Some(error), + ), + TryConnectCustomAgentResponse::FailAcp { error } => ( + AgentSnapshotCheckStatus::Unavailable, + Some("acp_init_failed".to_owned()), + Some(error), + ), + } + } else if let Some(backend) = meta.backend.as_deref() { + let result = cli_detect::health_check(registry, backend).await; + if result.available { + (AgentSnapshotCheckStatus::Available, None, None) + } else { + ( + AgentSnapshotCheckStatus::Unavailable, + Some("health_check_failed".to_owned()), + result.error, + ) + } + } else { + (AgentSnapshotCheckStatus::Available, None, None) + }; + + let latency_ms = start.elapsed().as_millis() as i64; + let status = match status { + AgentSnapshotCheckStatus::Available => "available", + AgentSnapshotCheckStatus::Unavailable => "unavailable", + }; + + AvailabilitySnapshot { + status, + kind: match kind { + AgentSnapshotCheckKind::Startup => "startup", + AgentSnapshotCheckKind::Scheduled => "scheduled", + AgentSnapshotCheckKind::Manual => "manual", + AgentSnapshotCheckKind::Session => "session", + }, + error_code, + error_message, + latency_ms, + checked_at: started_at, + } +} + +#[async_trait::async_trait] +impl AgentAvailabilityFeedbackPort for AgentAvailabilityService { + async fn record_session_failure(&self, agent_id: &str, code: &str, message: &str) -> Result<(), AgentError> { + AgentAvailabilityService::record_session_failure(self, agent_id, code, message).await + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, atomic::AtomicBool}; + + use aionui_api_types::{AgentManagementStatus, AgentSnapshotCheckKind, AgentSnapshotCheckStatus}; + use aionui_db::{ + IAgentMetadataRepository, SqliteAgentMetadataRepository, UpsertAgentMetadataParams, init_database_memory, + }; + use tokio::time::Duration; + + use super::AgentAvailabilityService; + use crate::registry::AgentRegistry; + + #[tokio::test] + async fn record_session_failure_persists_unavailable_snapshot() { + let db = init_database_memory().await.unwrap(); + let repo: Arc = Arc::new(SqliteAgentMetadataRepository::new(db.pool().clone())); + + repo.upsert(&UpsertAgentMetadataParams { + id: "agent-session-failure", + icon: None, + name: "Session Failure Agent", + name_i18n: None, + description: None, + description_i18n: None, + backend: Some("claude"), + agent_type: "acp", + agent_source: "custom", + agent_source_info: Some(r#"{"binary_name":"cargo"}"#), + enabled: true, + command: Some("cargo"), + args: Some("[]"), + env: Some("[]"), + native_skills_dirs: None, + behavior_policy: None, + yolo_id: None, + agent_capabilities: None, + auth_methods: None, + config_options: None, + available_modes: None, + available_models: None, + available_commands: None, + sort_order: 100, + }) + .await + .unwrap(); + + let registry = AgentRegistry::new(repo); + registry.hydrate().await.unwrap(); + + let service = AgentAvailabilityService::new(registry.clone(), std::env::temp_dir()); + service + .record_session_failure( + "agent-session-failure", + "session_send_failed", + "provider returned 401 invalid api key", + ) + .await + .unwrap(); + + let row = service + .list_management_rows() + .await + .into_iter() + .find(|item| item.id == "agent-session-failure") + .unwrap(); + + assert_eq!(row.status, AgentManagementStatus::Unavailable); + assert_eq!(row.last_check_status, Some(AgentSnapshotCheckStatus::Unavailable)); + assert_eq!(row.last_check_kind, Some(AgentSnapshotCheckKind::Session)); + assert_eq!(row.last_check_error_code.as_deref(), Some("session_send_failed")); + assert_eq!( + row.last_check_error_message.as_deref(), + Some("provider returned 401 invalid api key") + ); + assert!(row.last_failure_at.is_some()); + } + + #[tokio::test] + async fn background_scheduler_persists_scheduled_snapshot() { + let db = init_database_memory().await.unwrap(); + let repo: Arc = Arc::new(SqliteAgentMetadataRepository::new(db.pool().clone())); + + repo.upsert(&UpsertAgentMetadataParams { + id: "agent-scheduled-check", + icon: None, + name: "Scheduled Check Agent", + name_i18n: None, + description: None, + description_i18n: None, + backend: Some("claude"), + agent_type: "acp", + agent_source: "custom", + agent_source_info: Some(r#"{"binary_name":"cargo"}"#), + enabled: true, + command: Some("cargo"), + args: Some("[]"), + env: Some("[]"), + native_skills_dirs: None, + behavior_policy: None, + yolo_id: None, + agent_capabilities: None, + auth_methods: None, + config_options: None, + available_modes: None, + available_models: None, + available_commands: None, + sort_order: 100, + }) + .await + .unwrap(); + + let registry = AgentRegistry::new(repo); + registry.hydrate().await.unwrap(); + + let service = AgentAvailabilityService { + registry: registry.clone(), + data_dir: std::env::temp_dir(), + scheduler_started: Arc::new(AtomicBool::new(false)), + startup_delay: Duration::from_millis(10), + scheduled_interval: Duration::from_secs(60), + }; + service.start_background_scheduler(); + + let mut row = None; + for _ in 0..20 { + let candidate = service + .list_management_rows() + .await + .into_iter() + .find(|item| item.id == "agent-scheduled-check") + .unwrap(); + if candidate.last_check_kind == Some(AgentSnapshotCheckKind::Scheduled) { + row = Some(candidate); + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + let row = row.expect("scheduled probe should persist a snapshot"); + + assert_eq!(row.last_check_kind, Some(AgentSnapshotCheckKind::Scheduled)); + assert!(row.last_check_status.is_some()); + assert!(row.last_check_at.is_some()); + } +} diff --git a/crates/aionui-ai-agent/src/services/mod.rs b/crates/aionui-ai-agent/src/services/mod.rs index 341e00884..48218d950 100644 --- a/crates/aionui-ai-agent/src/services/mod.rs +++ b/crates/aionui-ai-agent/src/services/mod.rs @@ -1,7 +1,9 @@ pub mod agent; +pub mod availability; pub mod custom; pub mod provider_health; pub mod remote; pub use agent::AgentService; +pub use availability::AgentAvailabilityFeedbackPort; pub use remote::RemoteAgentService; diff --git a/crates/aionui-ai-agent/tests/agent_availability_integration.rs b/crates/aionui-ai-agent/tests/agent_availability_integration.rs new file mode 100644 index 000000000..eca444e56 --- /dev/null +++ b/crates/aionui-ai-agent/tests/agent_availability_integration.rs @@ -0,0 +1,115 @@ +use std::sync::Arc; + +use aionui_ai_agent::AgentRegistry; +use aionui_api_types::{AgentManagementStatus, AgentSnapshotCheckKind, AgentSnapshotCheckStatus}; +use aionui_db::{ + IAgentMetadataRepository, SqliteAgentMetadataRepository, UpdateAgentAvailabilitySnapshotParams, + UpsertAgentMetadataParams, init_database_memory, +}; + +fn custom_params<'a>(id: &'a str, name: &'a str, command: &'a str) -> UpsertAgentMetadataParams<'a> { + UpsertAgentMetadataParams { + id, + icon: None, + name, + name_i18n: None, + description: None, + description_i18n: None, + backend: Some("claude"), + agent_type: "acp", + agent_source: "custom", + agent_source_info: Some(r#"{"binary_name":"claude"}"#), + enabled: true, + command: Some(command), + args: Some("[]"), + env: Some("[]"), + native_skills_dirs: None, + behavior_policy: None, + yolo_id: None, + agent_capabilities: None, + auth_methods: None, + config_options: None, + available_modes: None, + available_models: None, + available_commands: None, + sort_order: 100, + } +} + +#[tokio::test] +async fn management_rows_derive_missing_available_and_unavailable_statuses() { + let db = init_database_memory().await.unwrap(); + let repo: Arc = Arc::new(SqliteAgentMetadataRepository::new(db.pool().clone())); + + repo.upsert(&custom_params( + "agent-missing", + "Missing Agent", + "aionui-missing-agent-binary", + )) + .await + .unwrap(); + repo.upsert(&custom_params("agent-unavailable", "Unavailable Agent", "cargo")) + .await + .unwrap(); + repo.upsert(&custom_params("agent-available", "Available Agent", "cargo")) + .await + .unwrap(); + + repo.update_availability_snapshot( + "agent-unavailable", + &UpdateAgentAvailabilitySnapshotParams { + last_check_status: Some("unavailable"), + last_check_kind: Some("manual"), + last_check_error_code: Some("auth_required"), + last_check_error_message: Some("Login required"), + last_check_guidance: Some("Run cargo login"), + last_check_latency_ms: Some(320), + last_check_at: Some(1_750_000_000_000), + last_success_at: None, + last_failure_at: Some(1_750_000_000_000), + }, + ) + .await + .unwrap(); + + repo.update_availability_snapshot( + "agent-available", + &UpdateAgentAvailabilitySnapshotParams { + last_check_status: Some("available"), + last_check_kind: Some("scheduled"), + last_check_error_code: None, + last_check_error_message: None, + last_check_guidance: None, + last_check_latency_ms: Some(120), + last_check_at: Some(1_750_000_100_000), + last_success_at: Some(1_750_000_100_000), + last_failure_at: None, + }, + ) + .await + .unwrap(); + + let registry = AgentRegistry::new(repo); + registry.hydrate().await.unwrap(); + + let rows = registry.list_management_rows().await; + + let missing = rows.iter().find(|row| row.id == "agent-missing").unwrap(); + assert_eq!(missing.status, AgentManagementStatus::Missing); + assert_eq!(missing.last_check_status, None); + + let unavailable = rows.iter().find(|row| row.id == "agent-unavailable").unwrap(); + assert_eq!(unavailable.status, AgentManagementStatus::Unavailable); + assert_eq!( + unavailable.last_check_status, + Some(AgentSnapshotCheckStatus::Unavailable) + ); + assert_eq!(unavailable.last_check_kind, Some(AgentSnapshotCheckKind::Manual)); + assert_eq!(unavailable.last_check_error_code.as_deref(), Some("auth_required")); + + let available = rows.iter().find(|row| row.id == "agent-available").unwrap(); + assert_eq!(available.status, AgentManagementStatus::Available); + assert_eq!(available.last_check_status, Some(AgentSnapshotCheckStatus::Available)); + assert_eq!(available.last_check_kind, Some(AgentSnapshotCheckKind::Scheduled)); + assert_eq!(available.last_check_latency_ms, Some(120)); +} diff --git a/crates/aionui-api-types/src/agent_discovery.rs b/crates/aionui-api-types/src/agent_discovery.rs index b965186bd..9d9165d51 100644 --- a/crates/aionui-api-types/src/agent_discovery.rs +++ b/crates/aionui-api-types/src/agent_discovery.rs @@ -11,7 +11,7 @@ //! depend on the ACP protocol SDK — the ai-agent crate typed-decodes //! them when it needs to. -use aionui_common::AgentType; +use aionui_common::{AgentType, TimestampMs}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -106,6 +106,30 @@ pub struct AgentHandshake { pub available_commands: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentManagementStatus { + Missing, + Available, + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentSnapshotCheckStatus { + Available, + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentSnapshotCheckKind { + Startup, + Scheduled, + Manual, + Session, +} + /// The unified, decoded view of an `agent_metadata` row. /// /// Also the API response shape: `/api/agents` returns a list of these @@ -176,10 +200,85 @@ pub struct AgentMetadata { #[serde(default)] pub team_capable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_check_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_check_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_check_error_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_check_error_message: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_check_guidance: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_check_latency_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_check_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_success_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_failure_at: Option, + #[serde(default)] pub handshake: AgentHandshake, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentManagementRow { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name_i18n: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description_i18n: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend: Option, + pub agent_type: AgentType, + pub agent_source: AgentSource, + #[serde(default)] + pub agent_source_info: AgentSourceInfo, + pub enabled: bool, + pub installed: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub env: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_skills_dirs: Option>, + #[serde(default)] + pub behavior_policy: BehaviorPolicy, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub yolo_id: Option, + pub sort_order: i64, + #[serde(default)] + pub team_capable: bool, + pub status: AgentManagementStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_check_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_check_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_check_error_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_check_error_message: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_check_guidance: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_check_latency_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_check_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_success_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_failure_at: Option, +} + #[cfg(test)] mod tests { use super::*; @@ -224,6 +323,15 @@ mod tests { yolo_id: None, sort_order: 3100, team_capable: true, + last_check_status: None, + last_check_kind: None, + last_check_error_code: None, + last_check_error_message: None, + last_check_guidance: None, + last_check_latency_ms: None, + last_check_at: None, + last_success_at: None, + last_failure_at: None, handshake: AgentHandshake::default(), }; let v = serde_json::to_value(&meta).unwrap(); @@ -253,8 +361,15 @@ mod tests { assert_eq!(meta.agent_source, AgentSource::Custom); assert!(!meta.available); assert!(!meta.behavior_policy.supports_side_question); + assert!(meta.last_check_status.is_none()); assert!(meta.handshake.agent_capabilities.is_none()); } + + #[test] + fn agent_management_status_serializes_snake_case() { + let value = serde_json::to_value(AgentManagementStatus::Unavailable).unwrap(); + assert_eq!(value, json!("unavailable")); + } } #[cfg(test)] diff --git a/crates/aionui-api-types/src/assistant.rs b/crates/aionui-api-types/src/assistant.rs index e3fa9f191..cb89f37f4 100644 --- a/crates/aionui-api-types/src/assistant.rs +++ b/crates/aionui-api-types/src/assistant.rs @@ -7,6 +7,8 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use crate::AgentManagementStatus; + // --------------------------------------------------------------------------- // Response + source enum // --------------------------------------------------------------------------- @@ -16,6 +18,7 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "lowercase")] pub enum AssistantSource { Builtin, + Bare, User, } @@ -55,6 +58,13 @@ pub struct AssistantResponse { pub models: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_used_at: Option, + pub agent_status: AgentManagementStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_status_message: Option, + pub team_selectable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub team_block_reason: Option, + pub deletable: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -173,6 +183,13 @@ pub struct AssistantPreferencesResponse { pub struct AssistantDetailResponse { pub id: String, pub source: AssistantSource, + pub agent_status: AgentManagementStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_status_message: Option, + pub team_selectable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub team_block_reason: Option, + pub deletable: bool, pub profile: AssistantProfileResponse, pub state: AssistantStateResponse, pub engine: AssistantEngineResponse, @@ -301,6 +318,8 @@ mod tests { fn assistant_source_serializes_lowercase() { let json = serde_json::to_string(&AssistantSource::Builtin).unwrap(); assert_eq!(json, "\"builtin\""); + let json = serde_json::to_string(&AssistantSource::Bare).unwrap(); + assert_eq!(json, "\"bare\""); let json = serde_json::to_string(&AssistantSource::User).unwrap(); assert_eq!(json, "\"user\""); } diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index d6049f79f..0d733c412 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -42,7 +42,10 @@ pub use agent_build_extra::{ AcpBuildExtra, AcpModelInfo, AionrsBuildExtra, SessionMcpServer, SessionMcpTransport, SlashCommandCompletionBehavior, SlashCommandItem, }; -pub use agent_discovery::{AgentEnvEntry, AgentHandshake, AgentMetadata, AgentSource, AgentSourceInfo, BehaviorPolicy}; +pub use agent_discovery::{ + AgentEnvEntry, AgentHandshake, AgentManagementRow, AgentManagementStatus, AgentMetadata, AgentSnapshotCheckKind, + AgentSnapshotCheckStatus, AgentSource, AgentSourceInfo, BehaviorPolicy, +}; pub use agent_error::{ AgentErrorCode, AgentErrorOwnership, AgentErrorResolution, AgentErrorResolutionKind, AgentErrorResolutionTarget, AgentStreamErrorData, diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index 0b7760ec6..2698a8a6d 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -7,7 +7,9 @@ use std::sync::Arc; use std::time::Instant; use aionui_ai_agent::{AgentRouterState, AgentService, RemoteAgentRouterState, RemoteAgentService}; -use aionui_assistant::{AssistantRouterState, AssistantService, BuiltinAssistantRegistry}; +use aionui_assistant::{ + AssistantAgentCatalogPort, AssistantError, AssistantRouterState, AssistantService, BuiltinAssistantRegistry, +}; use aionui_auth::extract_token_from_ws_headers; use aionui_channel::ChannelRouterState; use aionui_conversation::{ConversationRouterState, ConversationService}; @@ -234,6 +236,10 @@ pub async fn build_module_states( encryption_key, services.data_dir.clone(), ); + agent_service.start_background_scheduler(); + services + .conversation_service + .with_agent_availability_feedback(agent_service.availability_feedback_port()); tracing::info!(elapsed_ms = boot.elapsed().as_millis(), "startup: agent service built"); tracing::info!( @@ -289,6 +295,19 @@ pub async fn build_module_states( /// Build the default `AssistantRouterState` from application services. pub fn build_assistant_state(services: &AppServices) -> AssistantRouterState { + #[derive(Clone)] + struct RegistryAssistantAgentCatalog { + registry: Arc, + } + + #[async_trait::async_trait] + impl AssistantAgentCatalogPort for RegistryAssistantAgentCatalog { + async fn list_management_agents(&self) -> Result, AssistantError> { + self.registry.refresh_availability().await; + Ok(self.registry.list_management_rows().await) + } + } + let pool = services.database.pool().clone(); let definition_repo: Arc = Arc::new(SqliteAssistantDefinitionRepository::new(pool.clone())); @@ -319,6 +338,9 @@ pub fn build_assistant_state(services: &AppServices) -> AssistantRouterState { override_repo, provider_repo, builtin, + agent_catalog: Some(Arc::new(RegistryAssistantAgentCatalog { + registry: services.agent_registry.clone(), + })), }, services.data_dir.clone(), )); diff --git a/crates/aionui-app/tests/acp_e2e.rs b/crates/aionui-app/tests/acp_e2e.rs index 206cd835d..d2f61fb45 100644 --- a/crates/aionui-app/tests/acp_e2e.rs +++ b/crates/aionui-app/tests/acp_e2e.rs @@ -9,6 +9,11 @@ use axum::http::StatusCode; use serde_json::json; use tower::ServiceExt; +use aionui_db::{ + IAgentMetadataRepository, SqliteAgentMetadataRepository, UpdateAgentAvailabilitySnapshotParams, + UpsertAgentMetadataParams, +}; + use common::{body_json, build_app, get_with_token, json_with_token, setup_and_login}; // ── Global ACP routes ──────────────────────────────────────────── @@ -111,6 +116,174 @@ async fn health_check_unknown_backend_reports_unavailable() { assert_eq!(body["data"]["available"], false); } +#[tokio::test] +async fn management_list_includes_missing_custom_agents() { + let (mut app, services) = build_app().await; + let (token, _csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; + + let repo: std::sync::Arc = + std::sync::Arc::new(SqliteAgentMetadataRepository::new(services.database.pool().clone())); + repo.upsert(&UpsertAgentMetadataParams { + id: "custom-missing-agent", + icon: None, + name: "Missing Custom Agent", + name_i18n: None, + description: None, + description_i18n: None, + backend: Some("claude"), + agent_type: "acp", + agent_source: "custom", + agent_source_info: Some(r#"{"binary_name":"aionui-missing-agent-binary"}"#), + enabled: true, + command: Some("aionui-missing-agent-binary"), + args: Some("[]"), + env: Some("[]"), + native_skills_dirs: None, + behavior_policy: None, + yolo_id: None, + agent_capabilities: None, + auth_methods: None, + config_options: None, + available_modes: None, + available_models: None, + available_commands: None, + sort_order: 1500, + }) + .await + .unwrap(); + services.agent_registry.invalidate_and_rehydrate().await.unwrap(); + + let req = get_with_token("/api/agents/management", &token); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = body_json(resp).await; + let rows = body["data"].as_array().expect("data should be an array"); + let row = rows + .iter() + .find(|item| item["id"].as_str() == Some("custom-missing-agent")) + .expect("management list should include missing custom agent"); + assert_eq!(row["status"], "missing"); +} + +#[tokio::test] +async fn list_agents_hides_rows_with_unavailable_snapshot() { + let (mut app, services) = build_app().await; + let (token, _csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; + + let repo: std::sync::Arc = + std::sync::Arc::new(SqliteAgentMetadataRepository::new(services.database.pool().clone())); + repo.upsert(&UpsertAgentMetadataParams { + id: "custom-unavailable-agent", + icon: None, + name: "Unavailable Custom Agent", + name_i18n: None, + description: None, + description_i18n: None, + backend: Some("claude"), + agent_type: "acp", + agent_source: "custom", + agent_source_info: Some(r#"{"binary_name":"cargo"}"#), + enabled: true, + command: Some("cargo"), + args: Some("[]"), + env: Some("[]"), + native_skills_dirs: None, + behavior_policy: None, + yolo_id: None, + agent_capabilities: None, + auth_methods: None, + config_options: None, + available_modes: None, + available_models: None, + available_commands: None, + sort_order: 1500, + }) + .await + .unwrap(); + repo.update_availability_snapshot( + "custom-unavailable-agent", + &UpdateAgentAvailabilitySnapshotParams { + last_check_status: Some("unavailable"), + last_check_kind: Some("scheduled"), + last_check_error_code: Some("acp_init_failed"), + last_check_error_message: Some("Synthetic unavailable snapshot"), + last_check_guidance: None, + last_check_latency_ms: Some(42), + last_check_at: Some(1_750_000_000_000), + last_success_at: None, + last_failure_at: Some(1_750_000_000_000), + }, + ) + .await + .unwrap(); + services.agent_registry.invalidate_and_rehydrate().await.unwrap(); + + let req = get_with_token("/api/agents", &token); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = body_json(resp).await; + let rows = body["data"].as_array().expect("data should be an array"); + assert!( + rows.iter() + .all(|item| item["id"].as_str() != Some("custom-unavailable-agent")), + "rows with last_check_status=unavailable should stay out of /api/agents" + ); +} + +#[tokio::test] +async fn health_check_by_id_returns_missing_status_for_uninstalled_agent() { + let (mut app, services) = build_app().await; + let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; + + let repo: std::sync::Arc = + std::sync::Arc::new(SqliteAgentMetadataRepository::new(services.database.pool().clone())); + repo.upsert(&UpsertAgentMetadataParams { + id: "custom-missing-agent", + icon: None, + name: "Missing Custom Agent", + name_i18n: None, + description: None, + description_i18n: None, + backend: Some("claude"), + agent_type: "acp", + agent_source: "custom", + agent_source_info: Some(r#"{"binary_name":"aionui-missing-agent-binary"}"#), + enabled: true, + command: Some("aionui-missing-agent-binary"), + args: Some("[]"), + env: Some("[]"), + native_skills_dirs: None, + behavior_policy: None, + yolo_id: None, + agent_capabilities: None, + auth_methods: None, + config_options: None, + available_modes: None, + available_models: None, + available_commands: None, + sort_order: 1500, + }) + .await + .unwrap(); + services.agent_registry.invalidate_and_rehydrate().await.unwrap(); + + let req = json_with_token( + "POST", + "/api/agents/custom-missing-agent/health-check", + json!({}), + &token, + &csrf, + ); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = body_json(resp).await; + assert_eq!(body["data"]["id"], "custom-missing-agent"); + assert_eq!(body["data"]["status"], "missing"); +} + // ── Session-bound ACP routes (no active task → 404) ────────────── #[tokio::test] diff --git a/crates/aionui-app/tests/assistants_e2e.rs b/crates/aionui-app/tests/assistants_e2e.rs index cbdd965ff..fb43d69ea 100644 --- a/crates/aionui-app/tests/assistants_e2e.rs +++ b/crates/aionui-app/tests/assistants_e2e.rs @@ -232,6 +232,7 @@ async fn fixture() -> Fixture { override_repo, provider_repo, builtin, + agent_catalog: None, }, user_data_dir.clone(), )); diff --git a/crates/aionui-assistant/src/agent_catalog.rs b/crates/aionui-assistant/src/agent_catalog.rs new file mode 100644 index 000000000..7ba5bf5c3 --- /dev/null +++ b/crates/aionui-assistant/src/agent_catalog.rs @@ -0,0 +1,8 @@ +use aionui_api_types::AgentManagementRow; + +use crate::error::AssistantError; + +#[async_trait::async_trait] +pub trait AssistantAgentCatalogPort: Send + Sync { + async fn list_management_agents(&self) -> Result, AssistantError>; +} diff --git a/crates/aionui-assistant/src/lib.rs b/crates/aionui-assistant/src/lib.rs index 8c3489fce..7b84d6fe3 100644 --- a/crates/aionui-assistant/src/lib.rs +++ b/crates/aionui-assistant/src/lib.rs @@ -6,12 +6,14 @@ //! assistant loading from on-disk manifest, and merge logic for //! `GET /api/assistants` across builtin + user + extension sources. +pub mod agent_catalog; pub mod builtin; pub mod error; pub mod routes; pub mod service; pub mod state; +pub use agent_catalog::AssistantAgentCatalogPort; pub use builtin::{AvatarAsset, BuiltinAssistant, BuiltinAssistantRegistry}; pub use error::AssistantError; pub use routes::{AssistantRouterState, assistant_routes}; diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 30cf4d6fa..3851ff78f 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -6,12 +6,12 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use aionui_api_types::{ - AssistantCapabilitiesResponse, AssistantDefaultListRequest, AssistantDefaultListResponse, - AssistantDefaultScalarRequest, AssistantDefaultScalarResponse, AssistantDefaultsRequest, AssistantDefaultsResponse, - AssistantDetailResponse, AssistantEngineResponse, AssistantPreferencesResponse, AssistantProfileResponse, - AssistantPromptsResponse, AssistantResponse, AssistantRulesResponse, AssistantSource, AssistantStateResponse, - CreateAssistantRequest, ImportAssistantsRequest, ImportAssistantsResult, ImportError, SetAssistantStateRequest, - UpdateAssistantRequest, + AgentManagementRow, AgentManagementStatus, AgentSource, AssistantCapabilitiesResponse, AssistantDefaultListRequest, + AssistantDefaultListResponse, AssistantDefaultScalarRequest, AssistantDefaultScalarResponse, + AssistantDefaultsRequest, AssistantDefaultsResponse, AssistantDetailResponse, AssistantEngineResponse, + AssistantPreferencesResponse, AssistantProfileResponse, AssistantPromptsResponse, AssistantResponse, + AssistantRulesResponse, AssistantSource, AssistantStateResponse, CreateAssistantRequest, ImportAssistantsRequest, + ImportAssistantsResult, ImportError, SetAssistantStateRequest, UpdateAssistantRequest, }; use aionui_common::{generate_prefixed_id, now_ms}; use aionui_db::{ @@ -24,6 +24,7 @@ use aionui_extension::{AssistantClassifier, AssistantRuleDispatcher, ExtensionEr use serde_json; use tracing::{debug, warn}; +use crate::agent_catalog::AssistantAgentCatalogPort; #[cfg(test)] use crate::builtin::BuiltinAssistant; use crate::builtin::{AvatarAsset, BuiltinAssistantRegistry}; @@ -43,6 +44,7 @@ pub struct AssistantService { /// pick an agent that actually matches the configured provider list. provider_repo: Arc, builtin: Arc, + agent_catalog: Option>, /// Root directory holding user-authored rule/skill md files and avatars. /// Defaults to `~/.aionui/` but can be overridden for tests. user_data_dir: PathBuf, @@ -56,6 +58,7 @@ pub struct AssistantServiceDeps { pub override_repo: Arc, pub provider_repo: Arc, pub builtin: Arc, + pub agent_catalog: Option>, } impl AssistantService { @@ -83,6 +86,7 @@ impl AssistantService { override_repo, provider_repo, builtin, + agent_catalog, } = deps; Self { pool, @@ -93,6 +97,7 @@ impl AssistantService { override_repo, provider_repo, builtin, + agent_catalog, user_data_dir, } } @@ -104,6 +109,7 @@ impl AssistantService { self.soft_delete_removed_builtin_definitions().await?; self.sync_legacy_user_assistants_to_new_tables().await?; self.sync_legacy_overrides_to_new_states().await?; + self.reconcile_generated_assistants().await?; self.rebuild_legacy_mirror_from_new_tables().await?; Ok(()) } @@ -242,6 +248,92 @@ impl AssistantService { Ok(()) } + async fn reconcile_generated_assistants(&self) -> Result, AssistantError> { + let Some(agent_catalog) = &self.agent_catalog else { + return Ok(Vec::new()); + }; + + let rows = agent_catalog.list_management_agents().await?; + for row in rows.iter().filter(|row| { + row.enabled + && row.agent_type.supports_new_conversation() + && matches!(row.status, AgentManagementStatus::Available) + }) { + if self + .definition_repo + .get_by_source_ref("generated", &row.id) + .await + .map_err(|e| AssistantError::Internal(format!("get generated assistant by source_ref: {e}")))? + .is_some() + { + continue; + } + + let assistant_key = format!("bare:{}", row.id); + let (definition_id, assistant_key) = self + .resolve_definition_identity("generated", Some(&row.id), &assistant_key) + .await?; + let avatar_value = row.icon.as_deref().filter(|value| !value.trim().is_empty()); + let backend = row.backend.as_deref().unwrap_or(""); + + self.definition_repo + .upsert(&UpsertAssistantDefinitionParams { + definition_id: &definition_id, + assistant_key: &assistant_key, + source: "generated", + owner_type: "system", + source_ref: Some(&row.id), + source_version: None, + source_hash: None, + name: &row.name, + name_i18n: "{}", + description: row.description.as_deref(), + description_i18n: "{}", + avatar_type: if avatar_value.is_some() { "emoji" } else { "none" }, + avatar_value, + agent_backend: backend, + rule_resource_type: "none", + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]", + recommended_prompts_i18n: "{}", + default_model_mode: "auto", + default_model_value: None, + default_permission_mode: "auto", + default_permission_value: None, + default_skills_mode: "auto", + default_skill_ids: "[]", + custom_skill_names: "[]", + default_disabled_builtin_skill_ids: "[]", + default_mcps_mode: "auto", + default_mcp_ids: "[]", + }) + .await + .map_err(|e| AssistantError::Internal(format!("upsert generated assistant definition: {e}")))?; + + if self + .state_repo + .get(&definition_id) + .await + .map_err(|e| AssistantError::Internal(format!("get generated assistant overlay: {e}")))? + .is_none() + { + self.state_repo + .upsert(&UpsertAssistantOverlayParams { + definition_id: &definition_id, + enabled: true, + sort_order: row.sort_order.clamp(i32::MIN as i64, i32::MAX as i64) as i32, + agent_backend_override: None, + last_used_at: None, + }) + .await + .map_err(|e| AssistantError::Internal(format!("upsert generated assistant overlay: {e}")))?; + } + } + + Ok(rows) + } + async fn upsert_definition_from_legacy_user_row(&self, row: &AssistantRow) -> Result<(), AssistantError> { // User-defined assistants do not expose locale-aware editing in the // current product. Keep the unified definition canonical fields as the @@ -435,6 +527,13 @@ impl AssistantService { if self.builtin.has(id) { return AssistantSource::Builtin; } + if let Ok(Some(definition)) = self.definition_repo.get_by_key(id).await { + return match definition.source.as_str() { + "builtin" => AssistantSource::Builtin, + "generated" => AssistantSource::Bare, + _ => AssistantSource::User, + }; + } AssistantSource::User } @@ -446,6 +545,7 @@ impl AssistantService { /// application. Also performs opportunistic orphan cleanup on the /// overrides table. pub async fn list(&self) -> Result, AssistantError> { + let projections = self.reconcile_generated_assistants().await?; let definitions = self .definition_repo .list() @@ -464,9 +564,12 @@ impl AssistantService { let mut result = Vec::new(); for definition in &definitions { + let projection = + assistant_projection_for_definition(definition, state_map.get(&definition.definition_id), &projections); result.push(definition_to_response( definition, state_map.get(&definition.definition_id), + &projection, )?); } @@ -488,20 +591,30 @@ impl AssistantService { } pub async fn get(&self, id: &str) -> Result { + let projections = self.reconcile_generated_assistants().await?; if let Some(definition) = self.definition_repo.get_by_key(id).await? { let state = self.state_repo.get(&definition.definition_id).await?; - return definition_to_response(&definition, state.as_ref()); + let projection = assistant_projection_for_definition(&definition, state.as_ref(), &projections); + return definition_to_response(&definition, state.as_ref(), &projection); } Err(AssistantError::NotFound(format!("assistant '{id}' not found"))) } pub async fn get_detail(&self, id: &str, locale: Option<&str>) -> Result { + let projections = self.reconcile_generated_assistants().await?; if let Some(definition) = self.definition_repo.get_by_key(id).await? { let state = self.state_repo.get(&definition.definition_id).await?; let preference = self.preference_repo.get(&definition.definition_id).await?; let rules_content = self.read_rule(id, locale).await?; - return definition_to_detail_response(&definition, state.as_ref(), preference.as_ref(), &rules_content); + let projection = assistant_projection_for_definition(&definition, state.as_ref(), &projections); + return definition_to_detail_response( + &definition, + state.as_ref(), + preference.as_ref(), + &rules_content, + &projection, + ); } Err(AssistantError::NotFound(format!("assistant '{id}' not found"))) @@ -686,7 +799,7 @@ impl AssistantService { .map_err(|e| AssistantError::Internal(format!("rebuild legacy mirror: {e}")))?; return self.get(id).await; } - AssistantSource::User => {} + AssistantSource::Bare | AssistantSource::User => {} } let serialized = SerializedFields::from_update(&req)?; @@ -885,6 +998,9 @@ impl AssistantService { AssistantSource::Builtin => { return Err(AssistantError::Forbidden("Cannot delete built-in assistant".into())); } + AssistantSource::Bare => { + return Err(AssistantError::Forbidden("Cannot delete generated assistant".into())); + } AssistantSource::User => {} } @@ -925,7 +1041,7 @@ impl AssistantService { req: SetAssistantStateRequest, ) -> Result { match self.classify_source(id).await { - AssistantSource::Builtin => {} + AssistantSource::Builtin | AssistantSource::Bare => {} AssistantSource::User => { // Confirm the user row exists (otherwise 404). if self.repo.get(id).await?.is_none() { @@ -1132,7 +1248,7 @@ impl AssistantService { .and_then(|b| String::from_utf8(b).ok()) .unwrap_or_default()) } - AssistantSource::User => { + AssistantSource::Bare | AssistantSource::User => { let path = self.user_rule_path(id, locale); Ok(read_file_or_empty(&path)) } @@ -1145,7 +1261,7 @@ impl AssistantService { AssistantSource::Builtin => Err(AssistantError::BadRequest( "Cannot write rule for built-in assistant".into(), )), - AssistantSource::User => { + AssistantSource::Bare | AssistantSource::User => { let path = self.user_rule_path(id, locale); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) @@ -1163,14 +1279,14 @@ impl AssistantService { AssistantSource::Builtin => Err(AssistantError::BadRequest( "Cannot delete rule for built-in assistant".into(), )), - AssistantSource::User => Ok(remove_assistant_md_files(&self.user_rules_dir(), id)), + AssistantSource::Bare | AssistantSource::User => Ok(remove_assistant_md_files(&self.user_rules_dir(), id)), } } pub async fn read_skill(&self, id: &str, locale: Option<&str>) -> Result { match self.classify_source(id).await { AssistantSource::Builtin => Ok(String::new()), - AssistantSource::User => { + AssistantSource::Bare | AssistantSource::User => { let path = self.user_skill_path(id, locale); Ok(read_file_or_empty(&path)) } @@ -1182,7 +1298,7 @@ impl AssistantService { AssistantSource::Builtin => Err(AssistantError::BadRequest( "Cannot write skill for built-in assistant".into(), )), - AssistantSource::User => { + AssistantSource::Bare | AssistantSource::User => { let path = self.user_skill_path(id, locale); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) @@ -1199,7 +1315,7 @@ impl AssistantService { AssistantSource::Builtin => Err(AssistantError::BadRequest( "Cannot delete skill for built-in assistant".into(), )), - AssistantSource::User => Ok(remove_assistant_md_files(&self.user_skills_dir(), id)), + AssistantSource::Bare | AssistantSource::User => Ok(remove_assistant_md_files(&self.user_skills_dir(), id)), } } @@ -1221,7 +1337,7 @@ impl AssistantService { pub async fn avatar_asset(&self, id: &str) -> Option { match self.classify_source(id).await { AssistantSource::Builtin => self.builtin.avatar_asset(id), - AssistantSource::User => { + AssistantSource::Bare | AssistantSource::User => { let dir = self.user_avatars_dir(); let entries = std::fs::read_dir(&dir).ok()?; for entry in entries.flatten() { @@ -1534,9 +1650,11 @@ fn parse_assistant_avatar_route(value: &str) -> Option { fn definition_to_response( definition: &AssistantDefinitionRow, state: Option<&AssistantOverlayRow>, + projection: &AssistantRuntimeProjection, ) -> Result { let source = match definition.source.as_str() { "builtin" => AssistantSource::Builtin, + "generated" => AssistantSource::Bare, _ => AssistantSource::User, }; let models = match ( @@ -1569,6 +1687,11 @@ fn definition_to_response( prompts_i18n: decode_list_map(Some(definition.recommended_prompts_i18n.as_str()))?, models, last_used_at: state.and_then(|row| row.last_used_at), + agent_status: projection.agent_status, + agent_status_message: projection.agent_status_message.clone(), + team_selectable: projection.team_selectable, + team_block_reason: projection.team_block_reason.clone(), + deletable: projection.deletable, }) } @@ -1577,6 +1700,7 @@ fn definition_to_detail_response( state: Option<&AssistantOverlayRow>, preference: Option<&aionui_db::AssistantPreferenceRow>, rules_content: &str, + projection: &AssistantRuntimeProjection, ) -> Result { let default_skill_ids = decode_str_list(Some(definition.default_skill_ids.as_str()))?; let custom_skill_names = decode_str_list(Some(definition.custom_skill_names.as_str()))?; @@ -1600,8 +1724,14 @@ fn definition_to_detail_response( id: definition.assistant_key.clone(), source: match definition.source.as_str() { "builtin" => AssistantSource::Builtin, + "generated" => AssistantSource::Bare, _ => AssistantSource::User, }, + agent_status: projection.agent_status, + agent_status_message: projection.agent_status_message.clone(), + team_selectable: projection.team_selectable, + team_block_reason: projection.team_block_reason.clone(), + deletable: projection.deletable, profile: AssistantProfileResponse { name: definition.name.clone(), name_i18n: decode_str_map(Some(definition.name_i18n.as_str()))?, @@ -1664,6 +1794,83 @@ fn definition_to_detail_response( }) } +#[derive(Debug, Clone)] +struct AssistantRuntimeProjection { + agent_status: AgentManagementStatus, + agent_status_message: Option, + team_selectable: bool, + team_block_reason: Option, + deletable: bool, +} + +fn assistant_projection_for_definition( + definition: &AssistantDefinitionRow, + state: Option<&AssistantOverlayRow>, + agent_rows: &[AgentManagementRow], +) -> AssistantRuntimeProjection { + let enabled = state.is_none_or(|row| row.enabled); + let source = match definition.source.as_str() { + "builtin" => AssistantSource::Builtin, + "generated" => AssistantSource::Bare, + _ => AssistantSource::User, + }; + let effective_backend = state + .and_then(|row| row.agent_backend_override.as_deref()) + .unwrap_or(definition.agent_backend.as_str()); + + let agent_row = if matches!(source, AssistantSource::Bare) { + definition + .source_ref + .as_deref() + .and_then(|source_ref| agent_rows.iter().find(|row| row.id == source_ref)) + } else { + agent_rows + .iter() + .find(|row| row.backend.as_deref() == Some(effective_backend) && row.agent_source != AgentSource::Custom) + .or_else(|| { + agent_rows + .iter() + .find(|row| row.backend.as_deref() == Some(effective_backend)) + }) + }; + + let agent_status = agent_row + .map(|row| row.status) + .unwrap_or(AgentManagementStatus::Missing); + let agent_status_message = agent_row.and_then(|row| { + row.last_check_error_message + .clone() + .or_else(|| row.last_check_guidance.clone()) + }); + let team_block_reason = if !enabled { + Some("Assistant is disabled.".to_string()) + } else { + match agent_row { + Some(row) if matches!(row.status, AgentManagementStatus::Missing) => { + Some("This assistant's agent is not installed.".to_string()) + } + Some(row) if matches!(row.status, AgentManagementStatus::Unavailable) => Some( + row.last_check_error_message + .clone() + .or_else(|| row.last_check_guidance.clone()) + .unwrap_or_else(|| "This assistant's agent is unavailable.".to_string()), + ), + Some(row) if !row.team_capable => Some("This assistant's agent does not support team mode.".to_string()), + None => Some("This assistant's agent could not be resolved.".to_string()), + _ => None, + } + }; + + AssistantRuntimeProjection { + agent_status, + agent_status_message, + team_selectable: enabled + && agent_row.is_some_and(|row| matches!(row.status, AgentManagementStatus::Available) && row.team_capable), + team_block_reason, + deletable: matches!(source, AssistantSource::User), + } +} + // --------------------------------------------------------------------------- // Serialization helpers // --------------------------------------------------------------------------- @@ -1985,6 +2192,18 @@ mod tests { _db: aionui_db::Database, } + #[derive(Clone, Default)] + struct StubAgentCatalog { + rows: Vec, + } + + #[async_trait::async_trait] + impl AssistantAgentCatalogPort for StubAgentCatalog { + async fn list_management_agents(&self) -> Result, AssistantError> { + Ok(self.rows.clone()) + } + } + /// Default fixture: seeded with a single OpenAI-compatible provider so /// `resolve_default_agent_type` returns `"aionrs"`. Tests that need to /// exercise the no-provider or anthropic-only branches construct their @@ -2011,6 +2230,7 @@ mod tests { /// Defaults to `"openai"` so existing tests get an `"aionrs"` /// default agent type. seed_platform: Option<&'static str>, + agent_rows: Vec, } async fn fixture_with_options(opts: FixtureOpts) -> Fixture { @@ -2066,6 +2286,9 @@ mod tests { override_repo: orepo, provider_repo: provider_repo.clone(), builtin: builtin_reg, + agent_catalog: Some(Arc::new(StubAgentCatalog { + rows: opts.agent_rows.clone(), + })), }, tmp.path().to_path_buf(), ); @@ -2122,6 +2345,48 @@ mod tests { } } + fn mk_agent_row( + id: &str, + backend: &str, + status: aionui_api_types::AgentManagementStatus, + ) -> aionui_api_types::AgentManagementRow { + aionui_api_types::AgentManagementRow { + id: id.into(), + icon: Some(format!("/api/assets/{backend}.svg")), + name: format!("{backend} agent"), + name_i18n: None, + description: None, + description_i18n: None, + backend: Some(backend.into()), + agent_type: aionui_common::AgentType::Acp, + agent_source: aionui_api_types::AgentSource::Builtin, + agent_source_info: aionui_api_types::AgentSourceInfo::default(), + enabled: true, + installed: true, + command: Some(backend.into()), + args: Vec::new(), + env: Vec::new(), + native_skills_dirs: None, + behavior_policy: aionui_api_types::BehaviorPolicy { + supports_team: true, + ..Default::default() + }, + yolo_id: None, + sort_order: 3100, + team_capable: true, + status, + last_check_status: Some(aionui_api_types::AgentSnapshotCheckStatus::Available), + last_check_kind: Some(aionui_api_types::AgentSnapshotCheckKind::Manual), + last_check_error_code: None, + last_check_error_message: None, + last_check_guidance: None, + last_check_latency_ms: Some(42), + last_check_at: Some(1_750_000_000_000), + last_success_at: Some(1_750_000_000_000), + last_failure_at: None, + } + } + #[tokio::test] async fn list_empty_is_empty() { let fx = fixture().await; @@ -2150,6 +2415,83 @@ mod tests { assert!(list.iter().any(|a| a.id == "u1")); } + #[tokio::test] + async fn list_maps_generated_definition_to_bare_source() { + let fx = fixture().await; + fx.definition_repo + .upsert(&UpsertAssistantDefinitionParams { + definition_id: "asstdef-generated", + assistant_key: "bare:claude", + source: "generated", + owner_type: "system", + source_ref: Some("agent-claude"), + source_version: None, + source_hash: None, + name: "Claude", + name_i18n: "{}", + description: None, + description_i18n: "{}", + avatar_type: "none", + avatar_value: None, + agent_backend: "claude", + rule_resource_type: "none", + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]", + recommended_prompts_i18n: "{}", + default_model_mode: "auto", + default_model_value: None, + default_permission_mode: "auto", + default_permission_value: None, + default_skills_mode: "auto", + default_skill_ids: "[]", + custom_skill_names: "[]", + default_disabled_builtin_skill_ids: "[]", + default_mcps_mode: "auto", + default_mcp_ids: "[]", + }) + .await + .unwrap(); + fx.state_repo + .upsert(&UpsertAssistantOverlayParams { + definition_id: "asstdef-generated", + enabled: true, + sort_order: 3, + agent_backend_override: None, + last_used_at: None, + }) + .await + .unwrap(); + + let list = fx.service.list().await.unwrap(); + let generated = list.iter().find(|assistant| assistant.id == "bare:claude").unwrap(); + assert_eq!(generated.source, AssistantSource::Bare); + } + + #[tokio::test] + async fn bootstrap_materializes_bare_assistant_from_available_agent() { + let fx = fixture_with_options(FixtureOpts { + agent_rows: vec![mk_agent_row( + "agent-claude", + "claude", + aionui_api_types::AgentManagementStatus::Available, + )], + ..Default::default() + }) + .await; + + let list = fx.service.list().await.unwrap(); + let bare = list + .iter() + .find(|assistant| assistant.id == "bare:agent-claude") + .unwrap(); + assert_eq!(bare.source, AssistantSource::Bare); + assert_eq!(bare.preset_agent_type, "claude"); + assert_eq!(bare.agent_status, aionui_api_types::AgentManagementStatus::Available); + assert!(bare.team_selectable); + assert!(!bare.deletable); + } + #[tokio::test] async fn bootstrap_materializes_builtin_and_syncs_legacy_rows() { let mut builtin = mk_builtin("builtin-office", "Office"); @@ -2996,6 +3338,7 @@ mod tests { override_repo: orepo, provider_repo, builtin: builtin_reg, + agent_catalog: None, }, tmp.path().to_path_buf(), ); diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 1af902ac8..f73a7bea8 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use aionui_ai_agent::session_context::{AgentSessionContext, AgentSessionKind}; use aionui_ai_agent::types::BuildTaskOptions; -use aionui_ai_agent::{AgentError, AgentInstance, AgentSendError, IWorkerTaskManager}; +use aionui_ai_agent::{AgentAvailabilityFeedbackPort, AgentError, AgentInstance, AgentSendError, IWorkerTaskManager}; use crate::response_middleware::ICronService; use crate::runtime_completion::RuntimeCompletionPublisher; @@ -123,6 +123,10 @@ struct AssistantSnapshot { avatar_type: String, #[serde(default)] avatar: Option, + #[serde(default)] + agent_id: Option, + #[serde(default)] + agent_source: Option, agent_backend: String, rules: AssistantSnapshotRules, #[serde(default)] @@ -248,6 +252,7 @@ pub struct ConversationService { assistant_state_repo: Arc>>>, assistant_preference_repo: Arc>>>, assistant_dispatcher: Arc>>>, + agent_availability_feedback: Arc>>>, runtime_state: Arc, // Repos for conversation, acp_session and agent_metadata access. @@ -314,6 +319,7 @@ impl ConversationService { assistant_state_repo: Arc::new(RwLock::new(None)), assistant_preference_repo: Arc::new(RwLock::new(None)), assistant_dispatcher: Arc::new(RwLock::new(None)), + agent_availability_feedback: Arc::new(RwLock::new(None)), runtime_state: Arc::new(ConversationRuntimeStateService::default()), conversation_repo, @@ -363,6 +369,12 @@ impl ConversationService { } } + pub fn with_agent_availability_feedback(&self, feedback: Arc) { + if let Ok(mut guard) = self.agent_availability_feedback.write() { + *guard = Some(feedback); + } + } + /// Register a hook to be notified when a conversation is deleted. /// /// Hooks are dispatched sequentially in registration order before @@ -436,6 +448,13 @@ impl ConversationService { .and_then(|guard| guard.as_ref().cloned()) } + pub(crate) fn agent_availability_feedback(&self) -> Option> { + self.agent_availability_feedback + .read() + .ok() + .and_then(|guard| guard.as_ref().cloned()) + } + pub(crate) fn runtime_persistence(&self) -> RuntimePersistenceCoordinator { RuntimePersistenceCoordinator::new(self.runtime_state()) } @@ -629,6 +648,25 @@ impl ConversationService { "preset_assistant_id".to_owned(), serde_json::Value::String(snapshot.assistant_id.clone()), ); + if !snapshot.agent_backend.is_empty() && !obj.contains_key("backend") { + obj.insert( + "backend".to_owned(), + serde_json::Value::String(snapshot.agent_backend.clone()), + ); + } + if let Some(agent_id) = snapshot.agent_id.as_ref() + && !obj.contains_key("agent_id") + { + obj.insert("agent_id".to_owned(), serde_json::Value::String(agent_id.clone())); + } + if let Some(agent_source) = snapshot.agent_source.as_ref() + && !obj.contains_key("agent_source") + { + obj.insert( + "agent_source".to_owned(), + serde_json::Value::String(agent_source.clone()), + ); + } if !snapshot.rules.content.is_empty() { obj.insert( "preset_context".to_owned(), @@ -1089,6 +1127,18 @@ impl ConversationService { .as_ref() .and_then(|row| row.agent_backend_override.clone()) .unwrap_or_else(|| definition.agent_backend.clone()); + let generated_agent = if definition.source == "generated" { + match definition.source_ref.as_deref() { + Some(agent_id) => self + .agent_metadata_repo + .get(agent_id) + .await + .map_err(|e| ConversationError::internal(format!("agent_metadata lookup failed: {e}")))?, + None => None, + } + } else { + None + }; Ok(Some(AssistantSnapshot { assistant_definition_id: definition.definition_id, @@ -1097,6 +1147,8 @@ impl ConversationService { name: definition.name, avatar_type: definition.avatar_type, avatar: definition.avatar_value, + agent_id: generated_agent.as_ref().map(|row| row.id.clone()), + agent_source: generated_agent.as_ref().map(|row| row.agent_source.clone()), agent_backend, rules: AssistantSnapshotRules { content: if rules_content.is_empty() { diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 38edf2333..53b082cc2 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -9,7 +9,7 @@ use std::time::Duration; use aionui_ai_agent::agent_task::{AgentInstance, IAgentTask, IMockAgent}; use aionui_ai_agent::protocol::events::{AgentStreamEvent, ErrorEventData, FinishEventData, TextEventData}; use aionui_ai_agent::types::{BuildTaskOptions, SendMessageData}; -use aionui_ai_agent::{AgentError, AgentSendError, IWorkerTaskManager}; +use aionui_ai_agent::{AgentAvailabilityFeedbackPort, AgentError, AgentSendError, IWorkerTaskManager}; use crate::response_middleware::{CronCommandResult, CronCreateParams, CronUpdateParams, ICronService}; use aionui_api_types::{ @@ -34,8 +34,9 @@ use aionui_db::{ IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, IAssistantPreferenceRepository, IConversationRepository, MessageRowUpdate, MessageSearchRow, PersistedSessionState, SaveRuntimeStateParams, SortOrder, SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, - SqliteAssistantPreferenceRepository, UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, - UpsertAssistantPreferenceParams, UpsertConversationAssistantSnapshotParams, init_database_memory, + SqliteAssistantPreferenceRepository, UpdateAgentAvailabilitySnapshotParams, UpsertAssistantDefinitionParams, + UpsertAssistantOverlayParams, UpsertAssistantPreferenceParams, UpsertConversationAssistantSnapshotParams, + init_database_memory, }; use aionui_extension::{AssistantRuleDispatcher, ExtensionError}; use aionui_realtime::EventBroadcaster; @@ -164,6 +165,30 @@ impl EventBroadcaster for MockBroadcaster { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct RecordedAvailabilityFailure { + agent_id: String, + code: String, + message: String, +} + +#[derive(Default)] +struct RecordingAvailabilityFeedback { + failures: Mutex>, +} + +#[async_trait::async_trait] +impl AgentAvailabilityFeedbackPort for RecordingAvailabilityFeedback { + async fn record_session_failure(&self, agent_id: &str, code: &str, message: &str) -> Result<(), AgentError> { + self.failures.lock().unwrap().push(RecordedAvailabilityFailure { + agent_id: agent_id.to_owned(), + code: code.to_owned(), + message: message.to_owned(), + }); + Ok(()) + } +} + // ── Mock Repository ──────────────────────────────────────────────── struct MockRepo { @@ -575,6 +600,13 @@ impl IAgentMetadataRepository for StubAgentMetadataRepo { ) -> Result, DbError> { Ok(None) } + async fn update_availability_snapshot( + &self, + _id: &str, + _params: &UpdateAgentAvailabilitySnapshotParams<'_>, + ) -> Result, DbError> { + Ok(None) + } async fn set_enabled(&self, _id: &str, _enabled: bool) -> Result { Ok(false) } @@ -3283,6 +3315,51 @@ async fn send_message_injects_send_error_when_runtime_terminal_missing() { assert_eq!(content["error"]["code"], "USER_LLM_PROVIDER_AUTH_FAILED"); } +#[tokio::test] +async fn send_message_records_agent_availability_feedback_on_send_failure() { + let (svc, _broadcaster, _repo, _default_task_mgr) = make_service(); + let task_mgr = Arc::new(MockTaskManager::new()); + let feedback = Arc::new(RecordingAvailabilityFeedback::default()); + svc.with_agent_availability_feedback(feedback.clone()); + + let mut create_req = make_create_req(); + create_req.name = Some("Feedback Conversation".into()); + create_req.source = Some(ConversationSource::Aionui); + create_req.extra = json!({ + "backend": "claude", + "agent_id": "agent-feedback-1", + "agent_source": "custom", + "workspace": ensure_test_workspace_path() + }); + + let conv = svc.create("user_1", create_req).await.unwrap(); + + let scripted_agent = Arc::new( + ScriptedAgent::new(&conv.id, vec![vec![]]) + .with_status(None) + .with_send_error(AgentSendError::from_agent_error(AgentError::bad_gateway( + "provider returned 401 invalid api key", + ))), + ); + task_mgr.insert_agent(&conv.id, AgentInstance::Mock(scripted_agent)); + + let task_mgr_dyn: Arc = task_mgr.clone(); + svc.send_message("user_1", &conv.id, make_send_req(), &task_mgr_dyn) + .await + .unwrap(); + wait_for_turn_released(&svc, &conv.id).await; + + let failures = feedback.failures.lock().unwrap().clone(); + assert_eq!( + failures, + vec![RecordedAvailabilityFailure { + agent_id: "agent-feedback-1".into(), + code: "session_send_failed".into(), + message: "provider returned 401 invalid api key".into(), + }] + ); +} + // ── stop_stream tests ─────────────────────────────────────────── #[tokio::test] diff --git a/crates/aionui-conversation/src/turn_orchestrator.rs b/crates/aionui-conversation/src/turn_orchestrator.rs index e3172ce29..3fd3ab345 100644 --- a/crates/aionui-conversation/src/turn_orchestrator.rs +++ b/crates/aionui-conversation/src/turn_orchestrator.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use aionui_ai_agent::types::{BuildTaskOptions, SendMessageData}; -use aionui_ai_agent::{AgentSendError, IWorkerTaskManager}; +use aionui_ai_agent::{AgentSendError, AgentSessionKind, IWorkerTaskManager}; use aionui_common::{ConversationStatus, ErrorChain, now_ms}; use aionui_db::models::ConversationRow; use tokio::sync::oneshot; @@ -56,6 +56,7 @@ impl ConversationTurnOrchestrator { let mut turn_claim = input.turn_claim; let conv_id = input.conversation.id.clone(); let turn_id = input.turn_id.clone(); + let availability_agent_id = availability_agent_id(&input.build_options); let build_started_at = now_ms(); let persistence = self.service.runtime_persistence(); let runtime_state = self.service.runtime_state(); @@ -75,6 +76,14 @@ impl ConversationTurnOrchestrator { error = %ErrorChain(&err), "Agent task build failed" ); + let failure_message = err.to_string(); + record_agent_session_failure( + &self.service, + availability_agent_id.as_deref(), + "session_build_failed", + &failure_message, + ) + .await; self.service .persist_and_broadcast_send_failure_tip(&conv_id, &turn_id, &send_error, Some(top_level_code)) .await; @@ -154,10 +163,20 @@ impl ConversationTurnOrchestrator { let send_agent = agent.clone(); let conv_id_send = conv_id.clone(); let turn_id_for_send = turn_id.clone(); + let feedback_service = self.service.clone(); + let feedback_agent_id = availability_agent_id.clone(); let (send_error_tx, send_error_rx) = oneshot::channel(); tokio::spawn(async move { if let Err(e) = send_agent.send_message(current_send).await { + let failure_message = availability_failure_message(&e); + record_agent_session_failure( + &feedback_service, + feedback_agent_id.as_deref(), + "session_send_failed", + &failure_message, + ) + .await; let task_status = send_agent.status(); let agent_type = send_agent.agent_type(); error!( @@ -244,3 +263,45 @@ impl ConversationTurnOrchestrator { } } } + +fn availability_agent_id(options: &BuildTaskOptions) -> Option { + match &options.context.kind { + AgentSessionKind::Acp(context) => context + .config + .agent_id + .as_deref() + .filter(|value| !value.is_empty()) + .map(str::to_owned), + AgentSessionKind::Aionrs(_) => None, + } +} + +fn availability_failure_message(error: &AgentSendError) -> String { + error + .stream_error() + .detail + .clone() + .unwrap_or_else(|| error.stream_error().message.clone()) +} + +async fn record_agent_session_failure( + service: &ConversationService, + agent_id: Option<&str>, + code: &str, + message: &str, +) { + let Some(agent_id) = agent_id else { + return; + }; + let Some(feedback) = service.agent_availability_feedback() else { + return; + }; + if let Err(error) = feedback.record_session_failure(agent_id, code, message).await { + warn!( + agent_id, + code, + error = %ErrorChain(&error), + "Failed to record agent availability session failure" + ); + } +} diff --git a/crates/aionui-db/migrations/013_agent_connection_snapshot.sql b/crates/aionui-db/migrations/013_agent_connection_snapshot.sql new file mode 100644 index 000000000..a77f53690 --- /dev/null +++ b/crates/aionui-db/migrations/013_agent_connection_snapshot.sql @@ -0,0 +1,14 @@ +-- Migration 013: persist the latest local-agent connection snapshot +-- +-- Stores the most recent availability probe or session-feedback result on +-- `agent_metadata`. These columns are snapshots, not the live runtime truth. + +ALTER TABLE agent_metadata ADD COLUMN last_check_status TEXT; +ALTER TABLE agent_metadata ADD COLUMN last_check_kind TEXT; +ALTER TABLE agent_metadata ADD COLUMN last_check_error_code TEXT; +ALTER TABLE agent_metadata ADD COLUMN last_check_error_message TEXT; +ALTER TABLE agent_metadata ADD COLUMN last_check_guidance TEXT; +ALTER TABLE agent_metadata ADD COLUMN last_check_latency_ms INTEGER; +ALTER TABLE agent_metadata ADD COLUMN last_check_at INTEGER; +ALTER TABLE agent_metadata ADD COLUMN last_success_at INTEGER; +ALTER TABLE agent_metadata ADD COLUMN last_failure_at INTEGER; diff --git a/crates/aionui-db/src/lib.rs b/crates/aionui-db/src/lib.rs index 0717f0595..c5ae47da5 100644 --- a/crates/aionui-db/src/lib.rs +++ b/crates/aionui-db/src/lib.rs @@ -13,9 +13,9 @@ pub use error::DbError; pub use models::{ AgentMetadataRow, AssistantDefinitionRow, AssistantOverlayRow, AssistantOverrideRow, AssistantPreferenceRow, AssistantRow, ConversationArtifactRow, ConversationAssistantSnapshotRow, CreateAssistantParams, - UpdateAgentHandshakeParams, UpdateAssistantParams, UpsertAgentMetadataParams, UpsertAssistantDefinitionParams, - UpsertAssistantOverlayParams, UpsertAssistantPreferenceParams, UpsertConversationAssistantSnapshotParams, - UpsertOverrideParams, + UpdateAgentAvailabilitySnapshotParams, UpdateAgentHandshakeParams, UpdateAssistantParams, + UpsertAgentMetadataParams, UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, + UpsertAssistantPreferenceParams, UpsertConversationAssistantSnapshotParams, UpsertOverrideParams, }; pub use repository::channel::UpdatePluginStatusParams; pub use repository::conversation::{ diff --git a/crates/aionui-db/src/models/agent_metadata.rs b/crates/aionui-db/src/models/agent_metadata.rs index 8e3bac821..16cec63a8 100644 --- a/crates/aionui-db/src/models/agent_metadata.rs +++ b/crates/aionui-db/src/models/agent_metadata.rs @@ -48,6 +48,16 @@ pub struct AgentMetadataRow { /// `007_agent_metadata_sort_order` migration for the range scheme. pub sort_order: i64, + pub last_check_status: Option, + pub last_check_kind: Option, + pub last_check_error_code: Option, + pub last_check_error_message: Option, + pub last_check_guidance: Option, + pub last_check_latency_ms: Option, + pub last_check_at: Option, + pub last_success_at: Option, + pub last_failure_at: Option, + pub created_at: TimestampMs, pub updated_at: TimestampMs, } @@ -97,3 +107,17 @@ pub struct UpdateAgentHandshakeParams<'a> { pub available_models: Option>, pub available_commands: Option>, } + +/// Snapshot of the latest availability check or session-feedback result. +#[derive(Debug, Clone, Default)] +pub struct UpdateAgentAvailabilitySnapshotParams<'a> { + pub last_check_status: Option<&'a str>, + pub last_check_kind: Option<&'a str>, + pub last_check_error_code: Option<&'a str>, + pub last_check_error_message: Option<&'a str>, + pub last_check_guidance: Option<&'a str>, + pub last_check_latency_ms: Option, + pub last_check_at: Option, + pub last_success_at: Option, + pub last_failure_at: Option, +} diff --git a/crates/aionui-db/src/models/mod.rs b/crates/aionui-db/src/models/mod.rs index 79573e647..a2f0b9756 100644 --- a/crates/aionui-db/src/models/mod.rs +++ b/crates/aionui-db/src/models/mod.rs @@ -16,7 +16,9 @@ mod team; mod user; pub use acp_session::AcpSessionRow; -pub use agent_metadata::{AgentMetadataRow, UpdateAgentHandshakeParams, UpsertAgentMetadataParams}; +pub use agent_metadata::{ + AgentMetadataRow, UpdateAgentAvailabilitySnapshotParams, UpdateAgentHandshakeParams, UpsertAgentMetadataParams, +}; pub use assistant::{ AssistantDefinitionRow, AssistantOverlayRow, AssistantOverrideRow, AssistantPreferenceRow, AssistantRow, CreateAssistantParams, UpdateAssistantParams, UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, diff --git a/crates/aionui-db/src/repository/agent_metadata.rs b/crates/aionui-db/src/repository/agent_metadata.rs index d99c57b89..9d4672f23 100644 --- a/crates/aionui-db/src/repository/agent_metadata.rs +++ b/crates/aionui-db/src/repository/agent_metadata.rs @@ -1,7 +1,9 @@ //! Repository trait for the `agent_metadata` catalog. use crate::error::DbError; -use crate::models::{AgentMetadataRow, UpdateAgentHandshakeParams, UpsertAgentMetadataParams}; +use crate::models::{ + AgentMetadataRow, UpdateAgentAvailabilitySnapshotParams, UpdateAgentHandshakeParams, UpsertAgentMetadataParams, +}; /// CRUD access for agent metadata rows. /// @@ -41,6 +43,14 @@ pub trait IAgentMetadataRepository: Send + Sync { params: &UpdateAgentHandshakeParams<'_>, ) -> Result, DbError>; + /// Persist the latest availability snapshot for an existing row. + /// Returns `Ok(None)` if no row matches `id`. + async fn update_availability_snapshot( + &self, + id: &str, + params: &UpdateAgentAvailabilitySnapshotParams<'_>, + ) -> Result, DbError>; + /// Toggle the `enabled` flag. Returns `true` if a row was updated. async fn set_enabled(&self, id: &str, enabled: bool) -> Result; diff --git a/crates/aionui-db/src/repository/sqlite_agent_metadata.rs b/crates/aionui-db/src/repository/sqlite_agent_metadata.rs index 0bd8e57a1..04135310b 100644 --- a/crates/aionui-db/src/repository/sqlite_agent_metadata.rs +++ b/crates/aionui-db/src/repository/sqlite_agent_metadata.rs @@ -4,7 +4,9 @@ use aionui_common::now_ms; use sqlx::SqlitePool; use crate::error::DbError; -use crate::models::{AgentMetadataRow, UpdateAgentHandshakeParams, UpsertAgentMetadataParams}; +use crate::models::{ + AgentMetadataRow, UpdateAgentAvailabilitySnapshotParams, UpdateAgentHandshakeParams, UpsertAgentMetadataParams, +}; use crate::repository::agent_metadata::IAgentMetadataRepository; #[derive(Clone, Debug)] @@ -191,6 +193,47 @@ impl IAgentMetadataRepository for SqliteAgentMetadataRepository { self.get(id).await } + async fn update_availability_snapshot( + &self, + id: &str, + params: &UpdateAgentAvailabilitySnapshotParams<'_>, + ) -> Result, DbError> { + let now = now_ms(); + let result = sqlx::query( + "UPDATE agent_metadata SET \ + last_check_status = ?, \ + last_check_kind = ?, \ + last_check_error_code = ?, \ + last_check_error_message = ?, \ + last_check_guidance = ?, \ + last_check_latency_ms = ?, \ + last_check_at = ?, \ + last_success_at = ?, \ + last_failure_at = ?, \ + updated_at = ? \ + WHERE id = ?", + ) + .bind(params.last_check_status) + .bind(params.last_check_kind) + .bind(params.last_check_error_code) + .bind(params.last_check_error_message) + .bind(params.last_check_guidance) + .bind(params.last_check_latency_ms) + .bind(params.last_check_at) + .bind(params.last_success_at) + .bind(params.last_failure_at) + .bind(now) + .bind(id) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Ok(None); + } + + self.get(id).await + } + async fn set_enabled(&self, id: &str, enabled: bool) -> Result { let now = now_ms(); let result = sqlx::query("UPDATE agent_metadata SET enabled = ?, updated_at = ? WHERE id = ?") @@ -431,6 +474,63 @@ mod tests { assert!(!repo.set_enabled("missing", true).await.unwrap()); } + #[tokio::test] + async fn update_availability_snapshot_persists_last_check_fields() { + let (repo, _db) = setup().await; + let row = repo + .upsert(&UpsertAgentMetadataParams { + id: "agent-claude", + icon: None, + name: "Claude Code", + name_i18n: None, + description: None, + description_i18n: None, + backend: Some("claude"), + agent_type: "acp", + agent_source: "builtin", + agent_source_info: None, + enabled: true, + command: Some("claude"), + args: None, + env: None, + native_skills_dirs: None, + behavior_policy: None, + yolo_id: None, + agent_capabilities: None, + auth_methods: None, + config_options: None, + available_modes: None, + available_models: None, + available_commands: None, + sort_order: 10, + }) + .await + .unwrap(); + + repo.update_availability_snapshot( + &row.id, + &crate::models::UpdateAgentAvailabilitySnapshotParams { + last_check_status: Some("available"), + last_check_kind: Some("manual"), + last_check_error_code: None, + last_check_error_message: None, + last_check_guidance: None, + last_check_latency_ms: Some(180), + last_check_at: Some(1_750_000_000_000), + last_success_at: Some(1_750_000_000_000), + last_failure_at: None, + }, + ) + .await + .unwrap(); + + let refreshed = repo.get(&row.id).await.unwrap().unwrap(); + assert_eq!(refreshed.last_check_status.as_deref(), Some("available")); + assert_eq!(refreshed.last_check_kind.as_deref(), Some("manual")); + assert_eq!(refreshed.last_check_latency_ms, Some(180)); + assert_eq!(refreshed.last_success_at, Some(1_750_000_000_000)); + } + #[tokio::test] async fn delete_removes_row() { let (repo, _db) = setup().await; diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index 6cbeb17a4..ab50d61d6 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -13,7 +13,8 @@ use aionui_ai_agent::{AgentError, IWorkerTaskManager, WorkerTaskManagerImpl}; use aionui_api_types::{AcpBuildExtra, AddAgentRequest, CreateTeamRequest, TeamAgentInput, WebSocketMessage}; use aionui_common::{AgentKillReason, AgentType, PaginatedResult, ProviderWithModel}; use aionui_db::models::{ - AgentMetadataRow, ConversationRow, MessageRow, UpdateAgentHandshakeParams, UpsertAgentMetadataParams, + AgentMetadataRow, ConversationRow, MessageRow, UpdateAgentAvailabilitySnapshotParams, UpdateAgentHandshakeParams, + UpsertAgentMetadataParams, }; use aionui_db::{ ConversationFilters, ConversationRowUpdate, DbError, IAgentMetadataRepository, IConversationRepository, @@ -663,6 +664,13 @@ impl IAgentMetadataRepository for StubAgentMetadataRepo { ) -> Result, DbError> { Ok(None) } + async fn update_availability_snapshot( + &self, + _id: &str, + _params: &UpdateAgentAvailabilitySnapshotParams<'_>, + ) -> Result, DbError> { + Ok(None) + } async fn set_enabled(&self, _id: &str, _enabled: bool) -> Result { Ok(false) } From 9ced726d6185708ec23eef66b3b789c7e0a7a696 Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 16 Jun 2026 10:50:15 +0800 Subject: [PATCH 002/135] chore(assistant): remove unused preset id whitelist asset --- .../preset-id-whitelist.json | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 crates/aionui-app/assets/builtin-assistants/preset-id-whitelist.json diff --git a/crates/aionui-app/assets/builtin-assistants/preset-id-whitelist.json b/crates/aionui-app/assets/builtin-assistants/preset-id-whitelist.json deleted file mode 100644 index d9a5b0a63..000000000 --- a/crates/aionui-app/assets/builtin-assistants/preset-id-whitelist.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - "word-creator", - "word-form-creator", - "ppt-creator", - "excel-creator", - "morph-ppt", - "morph-ppt-3d", - "pitch-deck-creator", - "dashboard-creator", - "academic-paper", - "financial-model-creator", - "openclaw-setup", - "cowork", - "game-3d", - "ui-ux-pro-max", - "planning-with-files", - "human-3-coach", - "social-job-publisher", - "moltbook", - "beautiful-mermaid", - "story-roleplay" -] From 5f10d9456d7f539de4d34373537fdd15e317091d Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 16 Jun 2026 11:09:34 +0800 Subject: [PATCH 003/135] feat(assistant): prioritize bare assistants on first bootstrap --- crates/aionui-assistant/src/service.rs | 132 ++++++++++++++++++++++--- 1 file changed, 117 insertions(+), 15 deletions(-) diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 3851ff78f..02bdd499d 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -254,21 +254,43 @@ impl AssistantService { }; let rows = agent_catalog.list_management_agents().await?; - for row in rows.iter().filter(|row| { - row.enabled - && row.agent_type.supports_new_conversation() - && matches!(row.status, AgentManagementStatus::Available) - }) { - if self - .definition_repo - .get_by_source_ref("generated", &row.id) - .await - .map_err(|e| AssistantError::Internal(format!("get generated assistant by source_ref: {e}")))? - .is_some() - { - continue; - } + let definitions = self.definition_repo.list().await.map_err(|e| { + AssistantError::Internal(format!("list assistant definitions for generated reconcile: {e}")) + })?; + let generated_source_refs: HashSet = definitions + .iter() + .filter(|definition| definition.source == "generated") + .filter_map(|definition| definition.source_ref.clone()) + .collect(); + let has_existing_generated = !generated_source_refs.is_empty(); + let existing_min_sort_order = self + .state_repo + .list() + .await + .map_err(|e| AssistantError::Internal(format!("list assistant overlays for generated reconcile: {e}")))? + .into_iter() + .map(|state| state.sort_order) + .min() + .unwrap_or_default() + .min(0); + let generated_rows: Vec<&AgentManagementRow> = rows + .iter() + .filter(|row| { + row.enabled + && row.agent_type.supports_new_conversation() + && matches!(row.status, AgentManagementStatus::Available) + }) + .collect(); + let missing_generated_count = generated_rows + .iter() + .filter(|row| !generated_source_refs.contains(&row.id)) + .count(); + for (missing_index, row) in generated_rows + .into_iter() + .filter(|row| !generated_source_refs.contains(&row.id)) + .enumerate() + { let assistant_key = format!("bare:{}", row.id); let (definition_id, assistant_key) = self .resolve_definition_identity("generated", Some(&row.id), &assistant_key) @@ -318,11 +340,16 @@ impl AssistantService { .map_err(|e| AssistantError::Internal(format!("get generated assistant overlay: {e}")))? .is_none() { + let initial_generated_sort_order = if !has_existing_generated && missing_generated_count > 0 { + existing_min_sort_order as i64 - missing_generated_count as i64 + missing_index as i64 + } else { + row.sort_order + }; self.state_repo .upsert(&UpsertAssistantOverlayParams { definition_id: &definition_id, enabled: true, - sort_order: row.sort_order.clamp(i32::MIN as i64, i32::MAX as i64) as i32, + sort_order: initial_generated_sort_order.clamp(i32::MIN as i64, i32::MAX as i64) as i32, agent_backend_override: None, last_used_at: None, }) @@ -2492,6 +2519,81 @@ mod tests { assert!(!bare.deletable); } + #[tokio::test] + async fn bootstrap_places_new_bare_assistants_before_existing_assistants() { + let fx = fixture_with_options(FixtureOpts { + builtins: vec![mk_builtin("builtin-office", "Office")], + agent_rows: vec![ + mk_agent_row( + "agent-claude", + "claude", + aionui_api_types::AgentManagementStatus::Available, + ), + mk_agent_row( + "agent-codex", + "codex", + aionui_api_types::AgentManagementStatus::Available, + ), + ], + ..Default::default() + }) + .await; + + fx.service + .create(CreateAssistantRequest { + id: Some("u1".into()), + name: "Mine".into(), + ..req_default() + }) + .await + .unwrap(); + + let list = fx.service.list().await.unwrap(); + let ordered_ids: Vec<&str> = list.iter().map(|assistant| assistant.id.as_str()).collect(); + + assert_eq!(ordered_ids[0..2], ["bare:agent-claude", "bare:agent-codex"]); + assert!(ordered_ids[2..].contains(&"builtin-office")); + assert!(ordered_ids[2..].contains(&"u1")); + } + + #[tokio::test] + async fn reconcile_generated_assistants_preserves_existing_user_sort_order() { + let fx = fixture_with_options(FixtureOpts { + agent_rows: vec![mk_agent_row( + "agent-claude", + "claude", + aionui_api_types::AgentManagementStatus::Available, + )], + ..Default::default() + }) + .await; + + let first = fx.service.list().await.unwrap(); + let bare = first + .iter() + .find(|assistant| assistant.id == "bare:agent-claude") + .expect("bare assistant should exist after first reconcile"); + assert_eq!(bare.sort_order, -1); + + fx.service + .set_state( + "bare:agent-claude", + SetAssistantStateRequest { + sort_order: Some(9000), + ..Default::default() + }, + ) + .await + .unwrap(); + + let second = fx.service.list().await.unwrap(); + let bare_after_reconcile = second + .iter() + .find(|assistant| assistant.id == "bare:agent-claude") + .expect("bare assistant should still exist"); + assert_eq!(bare_after_reconcile.sort_order, 9000); + } + #[tokio::test] async fn bootstrap_materializes_builtin_and_syncs_legacy_rows() { let mut builtin = mk_builtin("builtin-office", "Office"); From 6523326ab296aa489597ce1d0dc52f743d39e314 Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 16 Jun 2026 14:39:24 +0800 Subject: [PATCH 004/135] test(assistant): cover bare assistant projection --- crates/aionui-app/tests/assistants_e2e.rs | 109 ++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/crates/aionui-app/tests/assistants_e2e.rs b/crates/aionui-app/tests/assistants_e2e.rs index 16f5ae979..82accc0ba 100644 --- a/crates/aionui-app/tests/assistants_e2e.rs +++ b/crates/aionui-app/tests/assistants_e2e.rs @@ -53,6 +53,57 @@ struct Fixture { _ext_tmp: TempDir, } +async fn insert_generated_bare_assistant(fx: &Fixture, assistant_key: &str, source_ref: &str, backend: &str, name: &str) { + let pool = fx.services.database.pool().clone(); + let definition_repo = SqliteAssistantDefinitionRepository::new(pool.clone()); + let overlay_repo = SqliteAssistantOverlayRepository::new(pool); + + definition_repo + .upsert(&UpsertAssistantDefinitionParams { + definition_id: &format!("asstdef-{assistant_key}"), + assistant_key, + source: "generated", + owner_type: "system", + source_ref: Some(source_ref), + source_version: None, + source_hash: None, + name, + name_i18n: "{}", + description: None, + description_i18n: "{}", + avatar_type: "none", + avatar_value: None, + agent_backend: backend, + rule_resource_type: "none", + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]", + recommended_prompts_i18n: "{}", + default_model_mode: "auto", + default_model_value: None, + default_permission_mode: "auto", + default_permission_value: None, + default_skills_mode: "auto", + default_skill_ids: "[]", + custom_skill_names: "[]", + default_disabled_builtin_skill_ids: "[]", + default_mcps_mode: "auto", + default_mcp_ids: "[]", + }) + .await + .unwrap(); + overlay_repo + .upsert(&UpsertAssistantOverlayParams { + definition_id: &format!("asstdef-{assistant_key}"), + enabled: true, + sort_order: 5, + agent_backend_override: None, + last_used_at: None, + }) + .await + .unwrap(); +} + /// Build the whole app with: /// - a manifest at `{builtin_tmp}/assets/assistants.json` registering two /// built-ins (`builtin-office` with rule/skill/avatar files on disk, and @@ -316,6 +367,36 @@ async fn list_builtin_file_avatar_is_served_via_assistant_avatar_route() { ); } +#[tokio::test] +async fn list_generated_assistant_exposes_bare_runtime_fields() { + let fx = fixture().await; + insert_generated_bare_assistant(&fx, "bare:agent-droid", "agent-droid", "droid", "Droid").await; + + let resp = fx + .app + .clone() + .oneshot(get_with_token("/api/assistants", &fx.token)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let json = body_json(resp).await; + let list = json["data"].as_array().unwrap(); + let bare = list + .iter() + .find(|assistant| assistant["id"] == "bare:agent-droid") + .expect("generated bare assistant missing from assistant list"); + + assert_eq!(bare["source"], "bare"); + assert_eq!(bare["deletable"], false); + assert_eq!(bare["agent_status"], "missing"); + assert_eq!(bare["agent_status_message"], Value::Null); + assert_eq!(bare["team_selectable"], false); + assert_eq!( + bare["team_block_reason"], + "This assistant's agent could not be resolved." + ); +} + #[tokio::test] async fn list_requires_auth() { let fx = fixture().await; @@ -451,6 +532,34 @@ async fn get_detail_returns_definition_state_preferences_and_rules() { assert_eq!(data["preferences"]["last_skill_ids"], json!(["pref-skill"])); } +#[tokio::test] +async fn get_detail_generated_assistant_exposes_bare_runtime_fields() { + let fx = fixture().await; + insert_generated_bare_assistant(&fx, "bare:agent-droid", "agent-droid", "droid", "Droid").await; + + let resp = fx + .app + .clone() + .oneshot(get_with_token("/api/assistants/bare:agent-droid?locale=en-US", &fx.token)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let json = body_json(resp).await; + let data = &json["data"]; + assert_eq!(data["id"], "bare:agent-droid"); + assert_eq!(data["source"], "bare"); + assert_eq!(data["deletable"], false); + assert_eq!(data["agent_status"], "missing"); + assert_eq!(data["agent_status_message"], Value::Null); + assert_eq!(data["team_selectable"], false); + assert_eq!( + data["team_block_reason"], + "This assistant's agent could not be resolved." + ); + assert_eq!(data["engine"]["agent_backend"], "droid"); +} + // =========================================================================== // POST /api/assistants // =========================================================================== From 6a040d939ecc6833e73b9445c090281ccb5917d0 Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 16 Jun 2026 15:59:57 +0800 Subject: [PATCH 005/135] feat(channel): add backend-owned channel settings API --- crates/aionui-api-types/src/channel.rs | 35 ++++++++ crates/aionui-api-types/src/lib.rs | 3 +- crates/aionui-app/tests/channel_e2e.rs | 86 +++++++++++++++++++ crates/aionui-channel/src/channel_settings.rs | 78 +++++++++++++++++ crates/aionui-channel/src/routes.rs | 74 ++++++++++++++-- 5 files changed, 270 insertions(+), 6 deletions(-) diff --git a/crates/aionui-api-types/src/channel.rs b/crates/aionui-api-types/src/channel.rs index 0260533df..70883f900 100644 --- a/crates/aionui-api-types/src/channel.rs +++ b/crates/aionui-api-types/src/channel.rs @@ -87,6 +87,41 @@ pub struct SyncChannelSettingsRequest { pub platform: String, } +/// Assistant binding for a channel platform. +/// +/// Stored as backend-owned business data and used to resolve which assistant +/// should handle new inbound channel conversations for a given platform. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChannelAssistantSetting { + #[serde(skip_serializing_if = "Option::is_none")] + pub assistant_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub backend: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +/// Default model reference for a channel platform. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChannelDefaultModelSetting { + pub id: String, + pub use_model: String, +} + +/// Aggregated settings payload for one channel platform. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChannelPlatformSettingsResponse { + pub platform: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub assistant: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_model: Option, +} + // --------------------------------------------------------------------------- // E. Plugin management — Response DTOs // --------------------------------------------------------------------------- diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index cb273747e..9c94b7661 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -64,7 +64,8 @@ pub use auth::{ WebuiChangeUsernameResponse, WebuiGenerateQrTokenResponse, WebuiResetPasswordResponse, WsTokenResponse, }; pub use channel::{ - ApprovePairingRequest, BridgeResponse, ChannelSessionResponse, ChannelUserResponse, DisablePluginRequest, + ApprovePairingRequest, BridgeResponse, ChannelAssistantSetting, ChannelDefaultModelSetting, + ChannelPlatformSettingsResponse, ChannelSessionResponse, ChannelUserResponse, DisablePluginRequest, EnablePluginRequest, PairingRequestResponse, PairingRequestedPayload, PluginStatusChangedPayload, PluginStatusResponse, RejectPairingRequest, RevokeUserRequest, SyncChannelSettingsRequest, TestPluginExtraConfig, TestPluginRequest, TestPluginResponse, UserAuthorizedPayload, diff --git a/crates/aionui-app/tests/channel_e2e.rs b/crates/aionui-app/tests/channel_e2e.rs index 642bf8c0b..e11bf2f86 100644 --- a/crates/aionui-app/tests/channel_e2e.rs +++ b/crates/aionui-app/tests/channel_e2e.rs @@ -313,6 +313,92 @@ async fn get_sessions_empty() { // §5 Settings sync // =========================================================================== +#[tokio::test] +async fn get_channel_settings_returns_empty_payload_by_default() { + let (mut app, services) = build_app().await; + let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + + let req = get_with_token("/api/channel/settings/telegram", &token); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let json = body_json(resp).await; + assert!(json["success"].as_bool().unwrap()); + assert_eq!(json["data"]["platform"], "telegram"); + assert!(json["data"]["assistant"].is_null()); + assert!(json["data"]["default_model"].is_null()); +} + +#[tokio::test] +async fn put_channel_assistant_setting_persists_binding() { + let (mut app, services) = build_app().await; + let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + + let req = json_with_token( + "PUT", + "/api/channel/settings/telegram/assistant", + json!({ + "assistant_id": "bare-claude", + "custom_agent_id": "bare-claude", + "backend": "claude", + "agent_type": "acp", + "name": "Claude", + }), + &token, + &csrf, + ); + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let req = get_with_token("/api/channel/settings/telegram", &token); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let json = body_json(resp).await; + assert_eq!( + json["data"]["assistant"], + json!({ + "assistant_id": "bare-claude", + "custom_agent_id": "bare-claude", + "backend": "claude", + "agent_type": "acp", + "name": "Claude", + }) + ); +} + +#[tokio::test] +async fn put_channel_default_model_setting_persists_model_ref() { + let (mut app, services) = build_app().await; + let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + + let req = json_with_token( + "PUT", + "/api/channel/settings/lark/default-model", + json!({ + "id": "provider-1", + "use_model": "gemini-2.5-pro", + }), + &token, + &csrf, + ); + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let req = get_with_token("/api/channel/settings/lark", &token); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let json = body_json(resp).await; + assert_eq!( + json["data"]["default_model"], + json!({ + "id": "provider-1", + "use_model": "gemini-2.5-pro", + }) + ); +} + // SS-1: Sync valid platform clears sessions #[tokio::test] async fn sync_settings_valid() { diff --git a/crates/aionui-channel/src/channel_settings.rs b/crates/aionui-channel/src/channel_settings.rs index ca4b5d386..22582a495 100644 --- a/crates/aionui-channel/src/channel_settings.rs +++ b/crates/aionui-channel/src/channel_settings.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use aionui_api_types::{ChannelAssistantSetting, ChannelDefaultModelSetting, ChannelPlatformSettingsResponse}; use aionui_common::ProviderWithModel; use aionui_db::IClientPreferenceRepository; use tracing::debug; @@ -111,6 +112,54 @@ impl ChannelSettingsService { use_model, })) } + + pub async fn get_platform_settings( + &self, + platform: PluginType, + ) -> Result { + let key_agent = agent_key(platform); + let key_model = model_key(platform); + let prefs = self.pref_repo.get_by_keys(&[&key_agent, &key_model]).await?; + + let mut assistant = None; + let mut default_model = None; + + for pref in prefs { + if pref.key == key_agent { + assistant = parse_channel_assistant_setting(&pref.value); + } else if pref.key == key_model { + default_model = parse_channel_model_setting(&pref.value); + } + } + + Ok(ChannelPlatformSettingsResponse { + platform: platform.to_string(), + assistant, + default_model, + }) + } + + pub async fn set_assistant_setting( + &self, + platform: PluginType, + assistant: &ChannelAssistantSetting, + ) -> Result<(), ChannelError> { + let payload = serde_json::to_string(assistant).map_err(ChannelError::Json)?; + let key = agent_key(platform); + self.pref_repo.upsert_batch(&[(&key, payload.as_str())]).await?; + Ok(()) + } + + pub async fn set_model_setting( + &self, + platform: PluginType, + model: &ChannelDefaultModelSetting, + ) -> Result<(), ChannelError> { + let payload = serde_json::to_string(model).map_err(ChannelError::Json)?; + let key = model_key(platform); + self.pref_repo.upsert_batch(&[(&key, payload.as_str())]).await?; + Ok(()) + } } fn agent_key(platform: PluginType) -> String { @@ -128,6 +177,35 @@ fn default_agent_config() -> ResolvedAgentConfig { } } +fn parse_channel_assistant_setting(value: &str) -> Option { + let parsed: serde_json::Value = serde_json::from_str(value).ok()?; + + if let Some(raw) = parsed.as_str() { + return Some(ChannelAssistantSetting { + assistant_id: None, + custom_agent_id: None, + backend: Some(raw.to_owned()), + agent_type: Some(backend_to_agent_type(raw)), + name: None, + }); + } + + Some(ChannelAssistantSetting { + assistant_id: parsed["assistant_id"].as_str().map(|s| s.to_owned()), + custom_agent_id: parsed["custom_agent_id"].as_str().map(|s| s.to_owned()), + backend: parsed["backend"].as_str().map(|s| s.to_owned()), + agent_type: parsed["agent_type"].as_str().map(|s| s.to_owned()), + name: parsed["name"].as_str().map(|s| s.to_owned()), + }) +} + +fn parse_channel_model_setting(value: &str) -> Option { + let parsed: serde_json::Value = serde_json::from_str(value).ok()?; + let id = parsed["id"].as_str()?.to_owned(); + let use_model = parsed["use_model"].as_str()?.to_owned(); + Some(ChannelDefaultModelSetting { id, use_model }) +} + /// Maps a backend identifier to the corresponding `AgentType` serde name. /// /// ACP-style backends (claude, gemini, codex, etc.) all map to "acp". diff --git a/crates/aionui-channel/src/routes.rs b/crates/aionui-channel/src/routes.rs index ebde507d3..370b62d10 100644 --- a/crates/aionui-channel/src/routes.rs +++ b/crates/aionui-channel/src/routes.rs @@ -4,14 +4,15 @@ use std::sync::Arc; use axum::Router; use axum::extract::rejection::JsonRejection; -use axum::extract::{Json, State}; -use axum::routing::{get, post}; +use axum::extract::{Json, Path, State}; +use axum::routing::{get, post, put}; use tracing::warn; use aionui_api_types::{ - ApiResponse, ApprovePairingRequest, BridgeResponse, ChannelSessionResponse, ChannelUserResponse, - DisablePluginRequest, EnablePluginRequest, PairingRequestResponse, PluginStatusResponse, RejectPairingRequest, - RevokeUserRequest, SyncChannelSettingsRequest, TestPluginRequest, TestPluginResponse, + ApiResponse, ApprovePairingRequest, BridgeResponse, ChannelAssistantSetting, ChannelDefaultModelSetting, + ChannelPlatformSettingsResponse, ChannelSessionResponse, ChannelUserResponse, DisablePluginRequest, + EnablePluginRequest, PairingRequestResponse, PluginStatusResponse, RejectPairingRequest, RevokeUserRequest, + SyncChannelSettingsRequest, TestPluginRequest, TestPluginResponse, }; use aionui_common::ApiError; use aionui_db::{DbError, IChannelRepository}; @@ -101,6 +102,15 @@ pub fn channel_routes(state: ChannelRouterState) -> Router { // Session management .route("/api/channel/sessions", get(get_active_sessions)) // Settings sync + .route("/api/channel/settings/{platform}", get(get_channel_settings)) + .route( + "/api/channel/settings/{platform}/assistant", + put(set_channel_assistant_setting), + ) + .route( + "/api/channel/settings/{platform}/default-model", + put(set_channel_default_model_setting), + ) .route("/api/channel/settings/sync", post(sync_channel_settings)) .with_state(state) } @@ -551,6 +561,60 @@ async fn get_active_sessions( Ok(Json(ApiResponse::ok(responses))) } +// --------------------------------------------------------------------------- +// Channel settings handlers +// --------------------------------------------------------------------------- + +/// `GET /api/channel/settings/:platform` — return backend-owned channel settings. +async fn get_channel_settings( + State(state): State, + Path(platform): Path, +) -> Result>, ApiError> { + let platform = PluginType::from_str_opt(&platform) + .ok_or_else(|| ApiError::BadRequest(format!("Invalid platform: {}", platform)))?; + + let settings = state.settings_service.get_platform_settings(platform).await?; + Ok(Json(ApiResponse::ok(settings))) +} + +/// `PUT /api/channel/settings/:platform/assistant` — persist assistant binding for a platform. +async fn set_channel_assistant_setting( + State(state): State, + Path(platform): Path, + body: Result, JsonRejection>, +) -> Result>, ApiError> { + let platform = PluginType::from_str_opt(&platform) + .ok_or_else(|| ApiError::BadRequest(format!("Invalid platform: {}", platform)))?; + let Json(req) = body.map_err(ApiError::from)?; + + state.settings_service.set_assistant_setting(platform, &req).await?; + + Ok(Json(ApiResponse::ok(BridgeResponse { + success: true, + message: Some("Assistant setting updated".into()), + error: None, + }))) +} + +/// `PUT /api/channel/settings/:platform/default-model` — persist default model for a platform. +async fn set_channel_default_model_setting( + State(state): State, + Path(platform): Path, + body: Result, JsonRejection>, +) -> Result>, ApiError> { + let platform = PluginType::from_str_opt(&platform) + .ok_or_else(|| ApiError::BadRequest(format!("Invalid platform: {}", platform)))?; + let Json(req) = body.map_err(ApiError::from)?; + + state.settings_service.set_model_setting(platform, &req).await?; + + Ok(Json(ApiResponse::ok(BridgeResponse { + success: true, + message: Some("Default model setting updated".into()), + error: None, + }))) +} + // --------------------------------------------------------------------------- // Settings sync handler // --------------------------------------------------------------------------- From ab35b57ddb3a005b2648aa5b301a0608bb3baadb Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 16 Jun 2026 16:01:35 +0800 Subject: [PATCH 006/135] chore: apply auto-fixes (fmt + clippy) --- crates/aionui-app/tests/assistants_e2e.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/aionui-app/tests/assistants_e2e.rs b/crates/aionui-app/tests/assistants_e2e.rs index 82accc0ba..3cc82b0a3 100644 --- a/crates/aionui-app/tests/assistants_e2e.rs +++ b/crates/aionui-app/tests/assistants_e2e.rs @@ -53,7 +53,13 @@ struct Fixture { _ext_tmp: TempDir, } -async fn insert_generated_bare_assistant(fx: &Fixture, assistant_key: &str, source_ref: &str, backend: &str, name: &str) { +async fn insert_generated_bare_assistant( + fx: &Fixture, + assistant_key: &str, + source_ref: &str, + backend: &str, + name: &str, +) { let pool = fx.services.database.pool().clone(); let definition_repo = SqliteAssistantDefinitionRepository::new(pool.clone()); let overlay_repo = SqliteAssistantOverlayRepository::new(pool); @@ -540,7 +546,10 @@ async fn get_detail_generated_assistant_exposes_bare_runtime_fields() { let resp = fx .app .clone() - .oneshot(get_with_token("/api/assistants/bare:agent-droid?locale=en-US", &fx.token)) + .oneshot(get_with_token( + "/api/assistants/bare:agent-droid?locale=en-US", + &fx.token, + )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); From 24fb0cedc6fbe9c3f36accd8150aadef993d6f13 Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 16 Jun 2026 16:03:59 +0800 Subject: [PATCH 007/135] test(api): refresh assistant response fixture --- crates/aionui-api-types/src/assistant.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/aionui-api-types/src/assistant.rs b/crates/aionui-api-types/src/assistant.rs index cb89f37f4..8a34bd2fa 100644 --- a/crates/aionui-api-types/src/assistant.rs +++ b/crates/aionui-api-types/src/assistant.rs @@ -346,6 +346,11 @@ mod tests { prompts_i18n: HashMap::new(), models: vec![], last_used_at: Some(1_234), + agent_status: AgentManagementStatus::Available, + agent_status_message: None, + team_selectable: true, + team_block_reason: None, + deletable: true, }; let json = serde_json::to_value(&resp).unwrap(); From 622610466d4769e11c2d4439a53321de9f81ae5d Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 16 Jun 2026 20:06:07 +0800 Subject: [PATCH 008/135] feat(channel): resolve bindings from assistants --- crates/aionui-app/src/router/state.rs | 7 +- crates/aionui-channel/src/channel_settings.rs | 270 ++++++++++++++++-- 2 files changed, 254 insertions(+), 23 deletions(-) diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index 03a2db6e3..b760fbbae 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -486,7 +486,12 @@ pub async fn build_channel_state( let pref_pool = services.database.pool().clone(); let pref_repo: Arc = Arc::new(SqliteClientPreferenceRepository::new(pref_pool)); - let channel_settings = Arc::new(aionui_channel::channel_settings::ChannelSettingsService::new(pref_repo)); + let channel_settings = Arc::new( + aionui_channel::channel_settings::ChannelSettingsService::new(pref_repo).with_assistant_repos( + Arc::new(SqliteAssistantDefinitionRepository::new(services.database.pool().clone())), + Arc::new(SqliteAssistantOverlayRepository::new(services.database.pool().clone())), + ), + ); // Build orchestrator dependencies let action_executor = Arc::new(aionui_channel::action::ActionExecutor::new( diff --git a/crates/aionui-channel/src/channel_settings.rs b/crates/aionui-channel/src/channel_settings.rs index 22582a495..69c089abb 100644 --- a/crates/aionui-channel/src/channel_settings.rs +++ b/crates/aionui-channel/src/channel_settings.rs @@ -2,13 +2,12 @@ use std::sync::Arc; use aionui_api_types::{ChannelAssistantSetting, ChannelDefaultModelSetting, ChannelPlatformSettingsResponse}; use aionui_common::ProviderWithModel; -use aionui_db::IClientPreferenceRepository; +use aionui_db::{IAssistantDefinitionRepository, IAssistantOverlayRepository, IClientPreferenceRepository}; use tracing::debug; use crate::error::ChannelError; use crate::types::PluginType; -const DEFAULT_BACKEND: &str = "aionrs"; const DEFAULT_AGENT_TYPE: &str = "aionrs"; /// Per-plugin agent/model configuration read from `client_preferences`. @@ -18,6 +17,8 @@ const DEFAULT_AGENT_TYPE: &str = "aionrs"; /// - `assistant.{platform}.defaultModel` → JSON `{"id":"provider_id","use_model":"model_name"}` pub struct ChannelSettingsService { pref_repo: Arc, + assistant_definition_repo: Option>, + assistant_overlay_repo: Option>, } /// Resolved agent configuration for a channel platform. @@ -40,7 +41,21 @@ pub struct ResolvedModelConfig { impl ChannelSettingsService { pub fn new(pref_repo: Arc) -> Self { - Self { pref_repo } + Self { + pref_repo, + assistant_definition_repo: None, + assistant_overlay_repo: None, + } + } + + pub fn with_assistant_repos( + mut self, + assistant_definition_repo: Arc, + assistant_overlay_repo: Arc, + ) -> Self { + self.assistant_definition_repo = Some(assistant_definition_repo); + self.assistant_overlay_repo = Some(assistant_overlay_repo); + self } /// Reads the agent configuration for a platform from `client_preferences`. @@ -58,30 +73,52 @@ impl ChannelSettingsService { return Ok(default_agent_config()); }; - let parsed: serde_json::Value = serde_json::from_str(&pref.value).unwrap_or_default(); + if let Some(setting) = parse_channel_assistant_setting(&pref.value) { + if let Some(assistant_id) = setting.assistant_id.as_deref() + && let Some(resolved) = self.resolve_assistant_agent_config(assistant_id).await? + { + debug!( + platform = %platform, + assistant_id, + agent_type = %resolved.agent_type, + backend = ?resolved.backend, + "resolved channel agent config from assistant identity" + ); + return Ok(resolved); + } - if let Some(at) = parsed["agent_type"].as_str() { - let backend = if at == "acp" { - parsed["backend"].as_str().map(|s| s.to_owned()) - } else { - None - }; + if let Some(at) = setting.agent_type.as_deref() { + let backend = if at == "acp" { + setting.backend.clone() + } else { + None + }; - debug!(platform = %platform, agent_type = %at, backend = ?backend, "resolved channel agent config (new format)"); + debug!(platform = %platform, agent_type = %at, backend = ?backend, "resolved channel agent config (new format)"); - return Ok(ResolvedAgentConfig { - agent_type: at.to_owned(), - backend, - }); - } + return Ok(ResolvedAgentConfig { + agent_type: at.to_owned(), + backend, + }); + } + + if let Some(raw_backend) = setting.backend.as_deref() { + let raw_backend = raw_backend.to_owned(); + let agent_type = backend_to_agent_type(&raw_backend); + let backend = if agent_type == "acp" { Some(raw_backend) } else { None }; - let raw_backend = parsed["backend"].as_str().unwrap_or(DEFAULT_BACKEND).to_owned(); - let agent_type = backend_to_agent_type(&raw_backend); - let backend = if agent_type == "acp" { Some(raw_backend) } else { None }; + debug!( + platform = %platform, + agent_type = %agent_type, + backend = ?backend, + "resolved channel agent config (legacy format)" + ); - debug!(platform = %platform, agent_type = %agent_type, backend = ?backend, "resolved channel agent config (legacy format)"); + return Ok(ResolvedAgentConfig { agent_type, backend }); + } + } - Ok(ResolvedAgentConfig { agent_type, backend }) + Ok(default_agent_config()) } /// Reads the model configuration for a platform from `client_preferences`. @@ -160,6 +197,31 @@ impl ChannelSettingsService { self.pref_repo.upsert_batch(&[(&key, payload.as_str())]).await?; Ok(()) } + + async fn resolve_assistant_agent_config( + &self, + assistant_id: &str, + ) -> Result, ChannelError> { + let (Some(definition_repo), Some(overlay_repo)) = + (&self.assistant_definition_repo, &self.assistant_overlay_repo) + else { + return Ok(None); + }; + + let Some(definition) = definition_repo.get_by_key(assistant_id).await? else { + return Ok(None); + }; + + let agent_backend = overlay_repo + .get(&definition.definition_id) + .await? + .and_then(|row| row.agent_backend_override) + .unwrap_or(definition.agent_backend); + let agent_type = backend_to_agent_type(&agent_backend); + let backend = if agent_type == "acp" { Some(agent_backend) } else { None }; + + Ok(Some(ResolvedAgentConfig { agent_type, backend })) + } } fn agent_key(platform: PluginType) -> String { @@ -244,7 +306,11 @@ pub fn resolved_model_to_provider(model: Option<&ResolvedModelConfig>) -> Provid mod tests { use super::*; use aionui_db::DbError; - use aionui_db::models::ClientPreference; + use aionui_db::models::{ + AssistantDefinitionRow, AssistantOverlayRow, ClientPreference, UpsertAssistantDefinitionParams, + UpsertAssistantOverlayParams, + }; + use aionui_db::{IAssistantDefinitionRepository, IAssistantOverlayRepository}; use std::sync::Mutex; struct MockPrefRepo { @@ -311,6 +377,128 @@ mod tests { } } + struct MockAssistantDefinitionRepo { + rows: Vec, + } + + #[async_trait::async_trait] + impl IAssistantDefinitionRepository for MockAssistantDefinitionRepo { + async fn list(&self) -> Result, DbError> { + Ok(self.rows.clone()) + } + + async fn get_by_key(&self, assistant_key: &str) -> Result, DbError> { + Ok(self.rows.iter().find(|row| row.assistant_key == assistant_key).cloned()) + } + + async fn get_by_definition_id(&self, definition_id: &str) -> Result, DbError> { + Ok(self + .rows + .iter() + .find(|row| row.definition_id == definition_id) + .cloned()) + } + + async fn get_by_source_ref( + &self, + source: &str, + source_ref: &str, + ) -> Result, DbError> { + Ok(self + .rows + .iter() + .find(|row| row.source == source && row.source_ref.as_deref() == Some(source_ref)) + .cloned()) + } + + async fn upsert( + &self, + _params: &UpsertAssistantDefinitionParams<'_>, + ) -> Result { + panic!("unused in channel settings tests") + } + + async fn soft_delete(&self, _definition_id: &str, _deleted_at: i64) -> Result { + panic!("unused in channel settings tests") + } + } + + struct MockAssistantOverlayRepo { + rows: Vec, + } + + #[async_trait::async_trait] + impl IAssistantOverlayRepository for MockAssistantOverlayRepo { + async fn get(&self, definition_id: &str) -> Result, DbError> { + Ok(self + .rows + .iter() + .find(|row| row.definition_id == definition_id) + .cloned()) + } + + async fn list(&self) -> Result, DbError> { + Ok(self.rows.clone()) + } + + async fn upsert(&self, _params: &UpsertAssistantOverlayParams<'_>) -> Result { + panic!("unused in channel settings tests") + } + + async fn delete(&self, _definition_id: &str) -> Result { + panic!("unused in channel settings tests") + } + } + + fn make_definition(assistant_key: &str, agent_backend: &str) -> AssistantDefinitionRow { + AssistantDefinitionRow { + definition_id: format!("def-{assistant_key}"), + assistant_key: assistant_key.to_owned(), + source: "generated".to_owned(), + owner_type: "system".to_owned(), + source_ref: Some(assistant_key.to_owned()), + source_version: None, + source_hash: None, + name: assistant_key.to_owned(), + name_i18n: "{}".to_owned(), + description: None, + description_i18n: "{}".to_owned(), + avatar_type: "emoji".to_owned(), + avatar_value: None, + agent_backend: agent_backend.to_owned(), + rule_resource_type: "inline".to_owned(), + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]".to_owned(), + recommended_prompts_i18n: "{}".to_owned(), + default_model_mode: "auto".to_owned(), + default_model_value: None, + default_permission_mode: "auto".to_owned(), + default_permission_value: None, + default_skills_mode: "auto".to_owned(), + default_skill_ids: "[]".to_owned(), + custom_skill_names: "[]".to_owned(), + default_disabled_builtin_skill_ids: "[]".to_owned(), + default_mcps_mode: "auto".to_owned(), + default_mcp_ids: "[]".to_owned(), + created_at: 0, + updated_at: 0, + deleted_at: None, + } + } + + fn make_overlay(definition_id: &str, agent_backend_override: &str) -> AssistantOverlayRow { + AssistantOverlayRow { + definition_id: definition_id.to_owned(), + enabled: true, + sort_order: 0, + agent_backend_override: Some(agent_backend_override.to_owned()), + last_used_at: None, + created_at: 0, + updated_at: 0, + } + } + // ── backend_to_agent_type ───────────────────────────────────────── #[test] @@ -427,6 +615,44 @@ mod tests { assert!(config.backend.is_none()); } + #[tokio::test] + async fn agent_config_resolves_backend_from_assistant_identity() { + let repo = Arc::new(MockPrefRepo::with_data(vec![( + "assistant.telegram.agent", + r#"{"assistant_id":"bare-claude","name":"Claude"}"#, + )])); + let definition_repo: Arc = Arc::new(MockAssistantDefinitionRepo { + rows: vec![make_definition("bare-claude", "claude")], + }); + let overlay_repo: Arc = + Arc::new(MockAssistantOverlayRepo { rows: vec![] }); + let svc = ChannelSettingsService::new(repo).with_assistant_repos(definition_repo, overlay_repo); + + let config = svc.get_agent_config(PluginType::Telegram).await.unwrap(); + assert_eq!(config.agent_type, "acp"); + assert_eq!(config.backend.as_deref(), Some("claude")); + } + + #[tokio::test] + async fn agent_config_prefers_overlay_backend_for_assistant_identity() { + let repo = Arc::new(MockPrefRepo::with_data(vec![( + "assistant.telegram.agent", + r#"{"assistant_id":"bare-claude","name":"Claude"}"#, + )])); + let definition = make_definition("bare-claude", "claude"); + let definition_repo: Arc = Arc::new(MockAssistantDefinitionRepo { + rows: vec![definition.clone()], + }); + let overlay_repo: Arc = Arc::new(MockAssistantOverlayRepo { + rows: vec![make_overlay(&definition.definition_id, "codex")], + }); + let svc = ChannelSettingsService::new(repo).with_assistant_repos(definition_repo, overlay_repo); + + let config = svc.get_agent_config(PluginType::Telegram).await.unwrap(); + assert_eq!(config.agent_type, "acp"); + assert_eq!(config.backend.as_deref(), Some("codex")); + } + // ── get_model_config ────────────────────────────────────────────── #[tokio::test] From c3e02ce7747677e93826cb1387f196c627ba933f Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 16 Jun 2026 20:50:53 +0800 Subject: [PATCH 009/135] fix(agent): kill probe process group to stop wrapper grandchild leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The availability scheduler runs `try_connect_custom_agent` every 5 minutes for every agent, spawning a CLI subprocess and tearing it down once the ACP handshake completes (or fails). For wrapper CLIs that fork a long-lived grandchild — `npm exec openclaw --acp` is the production case — cleanup was leaking the grandchild because: * `kill_on_drop(true)` on the tokio Command only signals the direct child (the npm exec wrapper), not its grandchild. * The probe relied on `drop(protocol)` for the success path and on no explicit cleanup for the handshake-fail path, so `proc.kill` was never called. * `CliAgentProcess::kill` itself short-circuited and returned Ok the moment the leader exited within the grace period — so even when callers did invoke it, no group-wide SIGKILL was sent. Result: dozens of zombie `openclaw-acp` processes accumulated per day under the 5-minute scheduler. Fix: 1. `CliAgentProcess::kill` now always issues a group-wide SIGKILL after the grace period, even when the leader has already exited. `force_kill` already maps ESRCH to success, so the sweep is idempotent for already-reaped trees. 2. `try_connect_custom_agent` calls `proc.kill` on every outcome (success, ACP failure, handshake timeout) by hoisting the spawn out of the inner future and running cleanup unconditionally after the timeout race resolves. 3. New regression test `probe_kills_grandchild_left_behind_by_wrapper` exercises the exact wrapper-grandchild shape from production and asserts the grandchild is reaped before the probe returns. --- .../src/capability/cli_process/mod.rs | 34 +++--- .../src/protocol/custom_agent_probe.rs | 115 ++++++++++++++++-- 2 files changed, 126 insertions(+), 23 deletions(-) diff --git a/crates/aionui-ai-agent/src/capability/cli_process/mod.rs b/crates/aionui-ai-agent/src/capability/cli_process/mod.rs index 089ad42e8..3b594414d 100644 --- a/crates/aionui-ai-agent/src/capability/cli_process/mod.rs +++ b/crates/aionui-ai-agent/src/capability/cli_process/mod.rs @@ -92,38 +92,44 @@ impl CliAgentProcess { } } - /// Gracefully terminate the subprocess. + /// Gracefully terminate the subprocess **and any descendants in its + /// process group**. /// /// 1. Close stdin - /// 2. Wait up to `grace_period` for the process to exit on its own - /// 3. If still running after grace period, send SIGKILL + /// 2. Wait up to `grace_period` for the leader to exit on its own + /// 3. SIGKILL the whole process group regardless of whether the leader + /// has already exited — wrapper CLIs (`npm exec ...`) routinely fork + /// a grandchild (`openclaw-acp`) that survives leader exit, and only + /// a group-wide kill reaps it pub async fn kill(&self, grace_period: Duration) -> Result<(), AgentError> { // Close stdin first to signal the child self.close_stdin().await; - // Wait for graceful exit within the grace period + // Wait up to the grace period for the leader to exit on its own. + // Even if it does, we still issue a group-wide SIGKILL below — the + // leader exiting tells us nothing about its grandchildren. let mut rx = self.exit_rx.clone(); - let exited = tokio::time::timeout(grace_period, async { - // If already exited, return immediately + let _ = tokio::time::timeout(grace_period, async { if rx.borrow().is_some() { return; } - // Wait for state change let _ = rx.changed().await; }) .await; - if exited.is_ok() && self.exit_rx.borrow().is_some() { - debug!(pid = self.pid, "CLI process exited gracefully"); - return Ok(()); + // Always sweep the process group. `force_kill` treats ESRCH as + // success, so this is idempotent when the leader (and group) are + // already gone. + if self.exit_rx.borrow().is_some() { + debug!(pid = self.pid, "CLI leader already exited; sweeping process group"); + } else { + warn!(pid = self.pid, "Grace period expired, sending SIGKILL"); } - - // Force kill - warn!(pid = self.pid, "Grace period expired, sending SIGKILL"); force_kill(self.pid, self.process_group_id)?; // Wait for the exit monitor to observe process termination so callers - // do not race a still-live child after force-kill returns. + // do not race a still-live leader after force-kill returns. Skip the + // wait if the leader had already exited before our sweep. let mut rx = self.exit_rx.clone(); tokio::time::timeout(Duration::from_secs(5), async { if rx.borrow().is_some() { diff --git a/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs b/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs index f202d7cff..a529c96a6 100644 --- a/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs +++ b/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs @@ -20,7 +20,7 @@ use aionui_api_types::TryConnectCustomAgentResponse; use aionui_common::{CommandSpec, EnvVar}; use aionui_runtime::{NodeRuntimeProgressReporter, ensure_runtime_command_with_reporter}; use tokio::sync::{broadcast, mpsc}; -use tracing::debug; +use tracing::{debug, warn}; use crate::capability::cli_process::CliAgentProcess; use crate::protocol::acp::AcpProtocol; @@ -30,6 +30,12 @@ use crate::protocol::acp::AcpProtocol; /// before writing any ACP frame at all is covered by this outer cap. const STEP2_TIMEOUT: Duration = Duration::from_secs(35); +/// Grace period for the child to exit on its own after stdin close, before +/// we fall back to SIGKILL on the whole process group. Short because the +/// availability scheduler runs this every 5 minutes for every agent and any +/// cleanup latency stacks up. +const PROBE_KILL_GRACE: Duration = Duration::from_millis(500); + /// Probe a custom ACP agent. /// /// Returns `Success` only if both `which` and the ACP `initialize` @@ -55,25 +61,40 @@ pub async fn try_connect_custom_agent( debug!(program = %resolved.program.display(), "probe step 1 ok"); // ── Step 2 — spawn + ACP initialize ───────────────────────────── - match tokio::time::timeout(STEP2_TIMEOUT, acp_initialize(resolved, args, env, data_dir)).await { + let proc = match spawn_probe_process(resolved, args, env, data_dir).await { + Ok(proc) => proc, + Err(msg) => return TryConnectCustomAgentResponse::FailAcp { error: msg }, + }; + + let outcome = match tokio::time::timeout(STEP2_TIMEOUT, run_handshake(&proc)).await { Ok(Ok(())) => TryConnectCustomAgentResponse::Success, Ok(Err(msg)) => TryConnectCustomAgentResponse::FailAcp { error: msg }, Err(_) => TryConnectCustomAgentResponse::FailAcp { error: format!("ACP initialize did not complete within {}s", STEP2_TIMEOUT.as_secs()), }, + }; + + // Always tear down the whole process group. `kill_on_drop(true)` only + // signals the direct child (e.g. `npm exec ...`) — wrapper CLIs spawn + // grandchildren (`openclaw-acp`) that survive unless we SIGKILL the + // group explicitly via `proc.kill()`. + if let Err(error) = proc.kill(PROBE_KILL_GRACE).await { + warn!(pid = proc.pid(), error = %error, "probe failed to kill process group"); } + + outcome } fn first_token(command: &str) -> &str { command.split_whitespace().next().unwrap_or(command) } -async fn acp_initialize( +async fn spawn_probe_process( resolved: aionui_runtime::ResolvedCommand, args: &[String], env: &HashMap, data_dir: &Path, -) -> Result<(), String> { +) -> Result { let mut final_args: Vec = resolved .args_prefix .iter() @@ -100,10 +121,12 @@ async fn acp_initialize( cwd: Some(std::env::temp_dir().to_string_lossy().into_owned()), }; - let proc = CliAgentProcess::spawn_for_sdk(spec, data_dir) + CliAgentProcess::spawn_for_sdk(spec, data_dir) .await - .map_err(|e| format!("spawn failed: {e}"))?; + .map_err(|e| format!("spawn failed: {e}")) +} +async fn run_handshake(proc: &CliAgentProcess) -> Result<(), String> { let (stdin, stdout) = proc .take_stdio() .await @@ -124,9 +147,9 @@ async fn acp_initialize( biased; res = connect => { let protocol = res.map_err(|e| format!("ACP initialize failed: {e}"))?; - // Dropping `protocol` fires the shutdown oneshot; the child - // process was spawned with `kill_on_drop(true)` via - // `aionui_runtime::Builder` so CPU stays clean. + // Drop `protocol` so its shutdown oneshot fires before the + // caller's process-group SIGKILL. The grace window in + // `proc.kill()` lets a well-behaved CLI exit on its own. drop(protocol); Ok(()) } @@ -184,4 +207,78 @@ mod tests { "expected FailAcp, got {resp:?}" ); } + + /// Regression for the production leak: a probe that talks to a wrapper + /// CLI (`npm exec ...`, etc.) historically left the wrapper's grandchild + /// process alive when the probe returned, because cleanup relied on + /// `kill_on_drop(true)` which only signals the direct child. Over many + /// availability scheduler iterations this accumulated dozens of zombie + /// `openclaw-acp` processes per day. + /// + /// We exercise the public entry point with a CLI that exits immediately + /// after backgrounding a long-lived grandchild — that's the production + /// shape `npm exec openclaw --acp` collapses into when its own ACP + /// handshake fails. The probe will see the wrapper exit (ACP fail), but + /// by that point the grandchild has been forked. The fix must SIGKILL + /// the whole process group before returning, so the grandchild dies too. + #[cfg(unix)] + #[tokio::test] + async fn probe_kills_grandchild_left_behind_by_wrapper() { + use std::time::Duration; + use tokio::time::Instant; + + fn is_pid_alive(pid: i32) -> bool { + unsafe { libc::kill(pid, 0) == 0 } + } + + let marker = tempfile::NamedTempFile::new().unwrap(); + let marker_path = marker.path().to_owned(); + // Background a grandchild, write its pid, then exit. The probe will + // observe `proc.wait_for_exit()` race the ACP handshake and return + // FailAcp — the grandchild keeps running unless cleanup kills it. + let script = format!("sleep 600 & printf '%s' \"$!\" > '{}'", marker_path.display()); + + let resp = try_connect_custom_agent( + "sh", + &["-c".to_string(), script], + &HashMap::new(), + &std::env::temp_dir(), + None, + ) + .await; + assert!( + matches!(resp, TryConnectCustomAgentResponse::FailAcp { .. }), + "wrapper exits before ACP handshake; expected FailAcp, got {resp:?}" + ); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut marker_contents = String::new(); + while Instant::now() < deadline { + if let Ok(s) = std::fs::read_to_string(&marker_path) + && !s.trim().is_empty() + { + marker_contents = s; + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + let grandchild_pid: i32 = marker_contents.trim().parse().unwrap_or_else(|_| { + panic!("wrapper did not write the grandchild pid: {marker_contents:?}"); + }); + + // Give the OS a brief moment to reap after the probe returned. + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + if !is_pid_alive(grandchild_pid) { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Cleanup so a failing test does not leave an actual leak. + unsafe { + libc::kill(grandchild_pid, libc::SIGKILL); + } + panic!("grandchild pid={grandchild_pid} survived the probe — process group cleanup is broken"); + } } From d821d4d950570ded7bb3e51f23d4b6d319707247 Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 16 Jun 2026 22:49:14 +0800 Subject: [PATCH 010/135] feat(team): persist assistant identity across team flows --- crates/aionui-api-types/src/team.rs | 33 ++++- crates/aionui-app/src/router/state.rs | 8 +- crates/aionui-team/src/guide/server.rs | 1 + crates/aionui-team/src/mcp/server.rs | 22 +++- crates/aionui-team/src/mcp/tools.rs | 21 ++-- crates/aionui-team/src/provisioning.rs | 51 ++++++-- crates/aionui-team/src/service.rs | 13 +- .../src/service/describe_support.rs | 116 ++++++++++++++++++ crates/aionui-team/src/test_utils.rs | 78 +++++++++++- crates/aionui-team/src/types.rs | 1 + .../tests/session_service_integration.rs | 107 +++++++++++++++- 11 files changed, 409 insertions(+), 42 deletions(-) create mode 100644 crates/aionui-team/src/service/describe_support.rs diff --git a/crates/aionui-api-types/src/team.rs b/crates/aionui-api-types/src/team.rs index 9e784052f..e0ba5b042 100644 --- a/crates/aionui-api-types/src/team.rs +++ b/crates/aionui-api-types/src/team.rs @@ -21,6 +21,8 @@ pub struct TeamAgentInput { pub backend: String, pub model: String, #[serde(default)] + pub assistant_id: Option, + #[serde(default)] pub custom_agent_id: Option, /// Adopt an existing conversation instead of creating a new one. /// When present the conversation's `extra` is updated with `teamId` @@ -62,6 +64,8 @@ pub struct AddAgentRequest { pub backend: String, pub model: String, #[serde(default)] + pub assistant_id: Option, + #[serde(default)] pub custom_agent_id: Option, } @@ -291,6 +295,8 @@ pub struct TeamAgentResponse { pub icon: Option, pub model: String, #[serde(skip_serializing_if = "Option::is_none")] + pub assistant_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub custom_agent_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, @@ -433,7 +439,7 @@ mod tests { "role": "lead", "backend": "acp", "model": "claude", - "custom_agent_id": "agent-x" + "assistant_id": "assistant-x" }, { "name": "Worker", @@ -450,8 +456,9 @@ mod tests { assert_eq!(req.agents[0].role, "lead"); assert_eq!(req.agents[0].backend, "acp"); assert_eq!(req.agents[0].model, "claude"); - assert_eq!(req.agents[0].custom_agent_id.as_deref(), Some("agent-x")); + assert_eq!(req.agents[0].assistant_id.as_deref(), Some("assistant-x")); assert_eq!(req.agents[1].name, "Worker"); + assert!(req.agents[1].assistant_id.is_none()); assert!(req.agents[1].custom_agent_id.is_none()); } @@ -546,6 +553,19 @@ mod tests { assert_eq!(req.custom_agent_id.as_deref(), Some("custom-1")); } + #[test] + fn deserialize_add_agent_request_with_assistant_id() { + let raw = json!({ + "name": "Custom", + "role": "teammate", + "backend": "acp", + "model": "claude", + "assistant_id": "assistant-1" + }); + let req: AddAgentRequest = serde_json::from_value(raw).unwrap(); + assert_eq!(req.assistant_id.as_deref(), Some("assistant-1")); + } + #[test] fn deserialize_add_agent_request_missing_name() { let raw = json!({ "role": "teammate", "backend": "acp", "model": "claude" }); @@ -616,6 +636,7 @@ mod tests { backend: "acp".into(), icon: Some("/api/assets/logos/ai-major/claude.svg".into()), model: "claude".into(), + assistant_id: Some("assistant-x".into()), custom_agent_id: Some("agent-x".into()), status: Some("idle".into()), pending_confirmations: 2, @@ -628,6 +649,7 @@ mod tests { assert_eq!(json["backend"], "acp"); assert_eq!(json["icon"], "/api/assets/logos/ai-major/claude.svg"); assert_eq!(json["model"], "claude"); + assert_eq!(json["assistant_id"], "assistant-x"); assert_eq!(json["custom_agent_id"], "agent-x"); assert_eq!(json["status"], "idle"); assert_eq!(json["pending_confirmations"], 2); @@ -643,6 +665,7 @@ mod tests { backend: "acp".into(), icon: None, model: "claude".into(), + assistant_id: None, custom_agent_id: None, status: None, pending_confirmations: 0, @@ -667,6 +690,7 @@ mod tests { backend: "acp".into(), icon: Some("/api/assets/logos/ai-major/claude.svg".into()), model: "claude".into(), + assistant_id: Some("assistant-x".into()), custom_agent_id: None, status: None, pending_confirmations: 0, @@ -729,6 +753,7 @@ mod tests { backend: "claude".into(), icon: Some("/api/assets/logos/ai-major/claude.svg".into()), model: "opus".into(), + assistant_id: None, custom_agent_id: None, status: Some("idle".into()), pending_confirmations: 0, @@ -778,6 +803,7 @@ mod tests { backend: "acp".into(), icon: Some("/api/assets/logos/ai-major/claude.svg".into()), model: "claude".into(), + assistant_id: Some("custom-1".into()), custom_agent_id: Some("custom-1".into()), status: Some("working".into()), pending_confirmations: 1, @@ -802,6 +828,7 @@ mod tests { backend: "acp".into(), icon: None, model: "claude".into(), + assistant_id: None, custom_agent_id: None, status: None, pending_confirmations: 0, @@ -814,6 +841,7 @@ mod tests { backend: "acp".into(), icon: Some("/api/assets/logos/tools/coding/codex.svg".into()), model: "claude".into(), + assistant_id: Some("x".into()), custom_agent_id: Some("x".into()), status: Some("idle".into()), pending_confirmations: 3, @@ -852,6 +880,7 @@ mod tests { backend: "claude".into(), icon: None, model: "sonnet".into(), + assistant_id: None, custom_agent_id: None, status: None, pending_confirmations: 0, diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index b760fbbae..183517d9f 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -488,7 +488,9 @@ pub async fn build_channel_state( Arc::new(SqliteClientPreferenceRepository::new(pref_pool)); let channel_settings = Arc::new( aionui_channel::channel_settings::ChannelSettingsService::new(pref_repo).with_assistant_repos( - Arc::new(SqliteAssistantDefinitionRepository::new(services.database.pool().clone())), + Arc::new(SqliteAssistantDefinitionRepository::new( + services.database.pool().clone(), + )), Arc::new(SqliteAssistantOverlayRepository::new(services.database.pool().clone())), ), ); @@ -605,6 +607,10 @@ pub fn build_team_state( let service = TeamSessionService::new( team_repo, Arc::new(SqliteAgentMetadataRepository::new(services.database.pool().clone())), + Arc::new(SqliteAssistantDefinitionRepository::new( + services.database.pool().clone(), + )), + Arc::new(SqliteAssistantOverlayRepository::new(services.database.pool().clone())), Arc::new(SqliteProviderRepository::new(services.database.pool().clone())), conversation_port, projection_store, diff --git a/crates/aionui-team/src/guide/server.rs b/crates/aionui-team/src/guide/server.rs index a0a2cfd25..84e0c81f0 100644 --- a/crates/aionui-team/src/guide/server.rs +++ b/crates/aionui-team/src/guide/server.rs @@ -247,6 +247,7 @@ async fn exec_create_team( role: "leader".to_owned(), backend: backend.clone(), model: model.clone(), + assistant_id: None, custom_agent_id: None, conversation_id: caller_conversation_id, }], diff --git a/crates/aionui-team/src/mcp/server.rs b/crates/aionui-team/src/mcp/server.rs index 71cbf954f..035751963 100644 --- a/crates/aionui-team/src/mcp/server.rs +++ b/crates/aionui-team/src/mcp/server.rs @@ -22,7 +22,7 @@ use super::protocol::{ }; use super::tools::{ RenameAgentInput, SendMessageInput, ShutdownAgentInput, SpawnAgentInput, TaskCreateInput, TaskUpdateInput, - all_tool_descriptors, handle_team_describe_assistant, handle_team_list_models, + all_tool_descriptors, handle_team_list_models, }; // --------------------------------------------------------------------------- @@ -444,7 +444,7 @@ pub(crate) async fn dispatch_tool( exec_shutdown_agent(arguments, scheduler, service, team_id, caller_slot_id, caller_role).await } "team_list_models" => exec_list_models(arguments, service).await, - "team_describe_assistant" => exec_describe_assistant(arguments).await, + "team_describe_assistant" => exec_describe_assistant(arguments, service).await, _ => Err(format!("Unknown tool: {tool_name}")), } } @@ -459,8 +459,22 @@ async fn exec_list_models(args: &Value, service: &Weak) -> R serde_json::to_string_pretty(&value).map_err(|e| format!("Serialization error: {e}")) } -async fn exec_describe_assistant(args: &Value) -> Result { - Ok(handle_team_describe_assistant(args)) +async fn exec_describe_assistant(args: &Value, service: &Weak) -> Result { + let assistant_key = args + .get("custom_agent_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Missing required field: custom_agent_id".to_owned())?; + let locale = args.get("locale").and_then(Value::as_str); + let service = service + .upgrade() + .ok_or_else(|| "Team service not available".to_owned())?; + + service + .describe_assistant(assistant_key, locale) + .await + .map_err(|error| error.to_string()) } // --------------------------------------------------------------------------- diff --git a/crates/aionui-team/src/mcp/tools.rs b/crates/aionui-team/src/mcp/tools.rs index 506eb9b24..79798c58b 100644 --- a/crates/aionui-team/src/mcp/tools.rs +++ b/crates/aionui-team/src/mcp/tools.rs @@ -452,12 +452,6 @@ pub fn build_list_models_from_rows( json!({ "agent_types": agent_types }) } -/// Phase-1 minimal `team_describe_assistant` handler. Backend has no preset -/// assistants wired yet, so every call returns the not-found text. -pub fn handle_team_describe_assistant(_args: &Value) -> String { - "Preset assistant not found".to_owned() -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -828,6 +822,15 @@ mod tests { available_models: Some(available_models.to_owned()), available_commands: None, sort_order: 0, + last_check_status: None, + last_check_kind: None, + last_check_error_code: None, + last_check_error_message: None, + last_check_guidance: None, + last_check_latency_ms: None, + last_check_at: None, + last_success_at: None, + last_failure_at: None, created_at: 0, updated_at: 0, } @@ -955,10 +958,4 @@ mod tests { .collect(); assert_eq!(models, vec!["gemini-3.1-pro-preview", "gpt-5.4", "gpt-5.2"]); } - - #[test] - fn team_describe_assistant_handler_returns_non_error() { - let text = handle_team_describe_assistant(&json!({"custom_agent_id": "unknown"})); - assert_eq!(text, "Preset assistant not found"); - } } diff --git a/crates/aionui-team/src/provisioning.rs b/crates/aionui-team/src/provisioning.rs index b1c71fef9..40b40803f 100644 --- a/crates/aionui-team/src/provisioning.rs +++ b/crates/aionui-team/src/provisioning.rs @@ -40,7 +40,7 @@ struct NewAgentProvisioning { role: TeammateRole, backend: String, model: String, - custom_agent_id: Option, + assistant_id: Option, workspace: Option, } @@ -91,6 +91,19 @@ pub trait TeamConversationProvisioningPort: Send + Sync { } impl TeamAgentProvisioner { + fn effective_assistant_id(assistant_id: Option<&str>, custom_agent_id: Option<&str>) -> Option { + assistant_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .or_else(|| { + custom_agent_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + }) + } + pub(crate) fn new( repo: Arc, provider_repo: Arc, @@ -129,7 +142,11 @@ impl TeamAgentProvisioner { &leader_input.name, &leader_input.backend, &leader_input.model, - leader_input.custom_agent_id.as_deref(), + Self::effective_assistant_id( + leader_input.assistant_id.as_deref(), + leader_input.custom_agent_id.as_deref(), + ) + .as_deref(), leader_input.conversation_id.as_deref(), shared_workspace, ) @@ -155,7 +172,10 @@ impl TeamAgentProvisioner { conversation_id: leader_conversation.conversation_id, backend: leader_input.backend.clone(), model: leader_input.model.clone(), - custom_agent_id: leader_input.custom_agent_id.clone(), + custom_agent_id: Self::effective_assistant_id( + leader_input.assistant_id.as_deref(), + leader_input.custom_agent_id.as_deref(), + ), status: None, conversation_type: None, cli_path: None, @@ -173,7 +193,8 @@ impl TeamAgentProvisioner { &input.name, &input.backend, &input.model, - input.custom_agent_id.as_deref(), + Self::effective_assistant_id(input.assistant_id.as_deref(), input.custom_agent_id.as_deref()) + .as_deref(), input.conversation_id.as_deref(), Some(&team_workspace), ) @@ -185,7 +206,10 @@ impl TeamAgentProvisioner { conversation_id: conversation.conversation_id, backend: input.backend.clone(), model: input.model.clone(), - custom_agent_id: input.custom_agent_id.clone(), + custom_agent_id: Self::effective_assistant_id( + input.assistant_id.as_deref(), + input.custom_agent_id.as_deref(), + ), status: None, conversation_type: None, cli_path: None, @@ -227,7 +251,7 @@ impl TeamAgentProvisioner { role, backend: req.backend, model: req.model, - custom_agent_id: req.custom_agent_id, + assistant_id: Self::effective_assistant_id(req.assistant_id.as_deref(), req.custom_agent_id.as_deref()), workspace: Some(workspace), }) .await?; @@ -260,7 +284,7 @@ impl TeamAgentProvisioner { role: TeammateRole::Teammate, backend, model, - custom_agent_id, + assistant_id: custom_agent_id, workspace: Some(workspace), }) .await?; @@ -342,7 +366,7 @@ impl TeamAgentProvisioner { &input.name, &input.backend, &input.model, - input.custom_agent_id.as_deref(), + input.assistant_id.as_deref(), None, input.workspace.as_deref(), ) @@ -354,7 +378,7 @@ impl TeamAgentProvisioner { conversation_id: conversation.conversation_id, backend: input.backend, model: input.model, - custom_agent_id: input.custom_agent_id, + custom_agent_id: input.assistant_id, status: None, conversation_type: None, cli_path: None, @@ -507,7 +531,7 @@ impl TeamAgentProvisioner { role: TeammateRole, backend: &str, model: &str, - custom_agent_id: Option<&str>, + assistant_id: Option<&str>, workspace: Option<&str>, ) -> Result { let mut extra = serde_json::json!({ @@ -520,9 +544,10 @@ impl TeamAgentProvisioner { if parse_agent_type(backend)? != AgentType::Aionrs { extra["current_model_id"] = serde_json::Value::String(model.to_owned()); } - if let Some(custom_agent_id) = custom_agent_id { - extra["custom_agent_id"] = serde_json::Value::String(custom_agent_id.to_owned()); - extra["preset_assistant_id"] = serde_json::Value::String(custom_agent_id.to_owned()); + if let Some(assistant_id) = assistant_id { + extra["assistant_id"] = serde_json::Value::String(assistant_id.to_owned()); + extra["custom_agent_id"] = serde_json::Value::String(assistant_id.to_owned()); + extra["preset_assistant_id"] = serde_json::Value::String(assistant_id.to_owned()); } if let Some(workspace) = workspace { inherit_team_workspace(&mut extra, workspace); diff --git a/crates/aionui-team/src/service.rs b/crates/aionui-team/src/service.rs index 5758958e2..92ee5f394 100644 --- a/crates/aionui-team/src/service.rs +++ b/crates/aionui-team/src/service.rs @@ -1,3 +1,4 @@ +mod describe_support; mod response_builder; pub(crate) mod spawn_support; @@ -11,7 +12,10 @@ use aionui_api_types::{ }; use aionui_common::{AgentKillReason, generate_id, now_ms}; use aionui_db::models::TeamRow; -use aionui_db::{IAgentMetadataRepository, IProviderRepository, ITeamRepository, UpdateTeamParams}; +use aionui_db::{ + IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, IProviderRepository, + ITeamRepository, UpdateTeamParams, +}; use aionui_realtime::EventBroadcaster; use dashmap::DashMap; use tracing::{info, warn}; @@ -42,6 +46,8 @@ struct SessionEntry { pub struct TeamSessionService { repo: Arc, agent_metadata_repo: Arc, + assistant_definition_repo: Arc, + assistant_overlay_repo: Arc, provider_repo: Arc, conversation_port: Arc, projection_store: Arc, @@ -73,10 +79,11 @@ pub struct TeamSessionService { } impl TeamSessionService { - #[allow(clippy::too_many_arguments)] pub fn new( repo: Arc, agent_metadata_repo: Arc, + assistant_definition_repo: Arc, + assistant_overlay_repo: Arc, provider_repo: Arc, conversation_port: Arc, projection_store: Arc, @@ -91,6 +98,8 @@ impl TeamSessionService { Arc::new_cyclic(|weak| Self { repo, agent_metadata_repo, + assistant_definition_repo, + assistant_overlay_repo, provider_repo, conversation_port, projection_store, diff --git a/crates/aionui-team/src/service/describe_support.rs b/crates/aionui-team/src/service/describe_support.rs new file mode 100644 index 000000000..bfbb26b83 --- /dev/null +++ b/crates/aionui-team/src/service/describe_support.rs @@ -0,0 +1,116 @@ +use std::collections::HashMap; +use std::fmt::Write; + +use crate::error::TeamError; +use crate::service::TeamSessionService; +use aionui_db::models::{AssistantDefinitionRow, AssistantOverlayRow}; + +impl TeamSessionService { + pub(crate) async fn describe_assistant( + &self, + assistant_key: &str, + locale: Option<&str>, + ) -> Result { + let definition = self + .assistant_definition_repo + .get_by_key(assistant_key) + .await? + .ok_or_else(|| TeamError::InvalidRequest(format!("Preset assistant not found: {assistant_key}")))?; + let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; + + Ok(render_assistant_description( + &definition, + overlay.as_ref(), + locale.unwrap_or("en-US"), + )) + } +} + +fn render_assistant_description( + definition: &AssistantDefinitionRow, + overlay: Option<&AssistantOverlayRow>, + locale: &str, +) -> String { + let effective_backend = overlay + .and_then(|row| row.agent_backend_override.as_deref()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(definition.agent_backend.as_str()); + let name_map = decode_str_map(&definition.name_i18n); + let description_map = decode_str_map(&definition.description_i18n); + let prompts_map = decode_list_map(&definition.recommended_prompts_i18n); + + let name = localized_text(&name_map, &definition.name, locale); + let description = localized_optional_text(&description_map, definition.description.as_deref(), locale) + .unwrap_or_else(|| "No description available.".to_owned()); + let example_tasks = localized_list(&prompts_map, &definition.recommended_prompts, locale).unwrap_or_default(); + let skills = decode_string_list(&definition.default_skill_ids) + .into_iter() + .chain(decode_string_list(&definition.custom_skill_names)) + .collect::>(); + + let mut out = String::new(); + let _ = writeln!(out, "# {} (`{}`)", name, definition.assistant_key); + let _ = writeln!(out); + let _ = writeln!(out, "Backend: {effective_backend}"); + let _ = writeln!(out); + let _ = writeln!(out, "## Description"); + let _ = writeln!(out, "{description}"); + let _ = writeln!(out); + let _ = writeln!(out, "## Skills"); + if skills.is_empty() { + let _ = writeln!(out, "- None"); + } else { + for skill in skills { + let _ = writeln!(out, "- {skill}"); + } + } + let _ = writeln!(out); + let _ = writeln!(out, "## Example tasks"); + if example_tasks.is_empty() { + let _ = writeln!(out, "- None"); + } else { + for task in example_tasks { + let _ = writeln!(out, "- {task}"); + } + } + let _ = writeln!(out); + let _ = writeln!( + out, + "Use `team_spawn_agent` with `custom_agent_id=\"{}\"`.", + definition.assistant_key + ); + out.trim_end().to_owned() +} + +fn decode_str_map(raw: &str) -> HashMap { + serde_json::from_str(raw).unwrap_or_default() +} + +fn decode_list_map(raw: &str) -> HashMap> { + serde_json::from_str(raw).unwrap_or_default() +} + +fn decode_string_list(raw: &str) -> Vec { + serde_json::from_str::>(raw).unwrap_or_default() +} + +fn localized_text(map: &HashMap, fallback: &str, locale: &str) -> String { + map.get(locale) + .or_else(|| map.get("en-US")) + .cloned() + .unwrap_or_else(|| fallback.to_owned()) +} + +fn localized_optional_text(map: &HashMap, fallback: Option<&str>, locale: &str) -> Option { + map.get(locale) + .or_else(|| map.get("en-US")) + .cloned() + .or_else(|| fallback.map(str::to_owned)) +} + +fn localized_list(map: &HashMap>, fallback_raw: &str, locale: &str) -> Option> { + map.get(locale).or_else(|| map.get("en-US")).cloned().or_else(|| { + let fallback = decode_string_list(fallback_raw); + if fallback.is_empty() { None } else { Some(fallback) } + }) +} diff --git a/crates/aionui-team/src/test_utils.rs b/crates/aionui-team/src/test_utils.rs index 863aa11df..9b19e7e43 100644 --- a/crates/aionui-team/src/test_utils.rs +++ b/crates/aionui-team/src/test_utils.rs @@ -196,12 +196,14 @@ pub(crate) mod workspace_harness { use aionui_api_types::{CreateTeamRequest, WebSocketMessage}; use aionui_common::{AgentKillReason, PaginatedResult, now_ms}; use aionui_db::models::{ - AgentMetadataRow, ConversationRow, MessageRow, TeamRow, TeamTaskRow, UpdateAgentHandshakeParams, - UpsertAgentMetadataParams, + AgentMetadataRow, AssistantDefinitionRow, AssistantOverlayRow, ConversationRow, MessageRow, TeamRow, + TeamTaskRow, UpdateAgentHandshakeParams, UpsertAgentMetadataParams, UpsertAssistantDefinitionParams, + UpsertAssistantOverlayParams, }; use aionui_db::{ - ConversationFilters, ConversationRowUpdate, DbError, IAgentMetadataRepository, IConversationRepository, - IProviderRepository, ITeamRepository, MessageRowUpdate, MessageSearchRow, SortOrder, UpdateTeamParams, + ConversationFilters, ConversationRowUpdate, DbError, IAgentMetadataRepository, IAssistantDefinitionRepository, + IAssistantOverlayRepository, IConversationRepository, IProviderRepository, ITeamRepository, MessageRowUpdate, + MessageSearchRow, SortOrder, UpdateTeamParams, }; use aionui_realtime::EventBroadcaster; use async_trait::async_trait; @@ -733,6 +735,14 @@ pub(crate) mod workspace_harness { Ok(None) } + async fn update_availability_snapshot( + &self, + _id: &str, + _params: &aionui_db::models::UpdateAgentAvailabilitySnapshotParams<'_>, + ) -> Result, DbError> { + Ok(None) + } + async fn set_enabled(&self, _id: &str, _enabled: bool) -> Result { Ok(false) } @@ -742,6 +752,63 @@ pub(crate) mod workspace_harness { } } + struct EmptyAssistantDefinitionRepo; + + #[async_trait] + impl IAssistantDefinitionRepository for EmptyAssistantDefinitionRepo { + async fn list(&self) -> Result, DbError> { + Ok(vec![]) + } + + async fn get_by_key(&self, _assistant_key: &str) -> Result, DbError> { + Ok(None) + } + + async fn get_by_definition_id(&self, _definition_id: &str) -> Result, DbError> { + Ok(None) + } + + async fn get_by_source_ref( + &self, + _source: &str, + _source_ref: &str, + ) -> Result, DbError> { + Ok(None) + } + + async fn upsert( + &self, + _params: &UpsertAssistantDefinitionParams<'_>, + ) -> Result { + Err(DbError::Init("not implemented".into())) + } + + async fn soft_delete(&self, _definition_id: &str, _deleted_at: i64) -> Result { + Ok(false) + } + } + + struct EmptyAssistantOverlayRepo; + + #[async_trait] + impl IAssistantOverlayRepository for EmptyAssistantOverlayRepo { + async fn get(&self, _definition_id: &str) -> Result, DbError> { + Ok(None) + } + + async fn list(&self) -> Result, DbError> { + Ok(vec![]) + } + + async fn upsert(&self, _params: &UpsertAssistantOverlayParams<'_>) -> Result { + Err(DbError::Init("not implemented".into())) + } + + async fn delete(&self, _definition_id: &str) -> Result { + Ok(false) + } + } + struct EmptyProviderRepo; #[async_trait] @@ -830,6 +897,8 @@ pub(crate) mod workspace_harness { let svc = TeamSessionService::new( team_repo_dyn, Arc::new(EmptyAgentMetadataRepo), + Arc::new(EmptyAssistantDefinitionRepo), + Arc::new(EmptyAssistantOverlayRepo), Arc::new(EmptyProviderRepo), conversation_port, projection_store, @@ -864,6 +933,7 @@ pub(crate) mod workspace_harness { role: "lead".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, conversation_id: None, }], diff --git a/crates/aionui-team/src/types.rs b/crates/aionui-team/src/types.rs index 859a00e5e..d1e663324 100644 --- a/crates/aionui-team/src/types.rs +++ b/crates/aionui-team/src/types.rs @@ -122,6 +122,7 @@ impl TeamAgent { backend: self.backend.clone(), icon, model: self.model.clone(), + assistant_id: self.custom_agent_id.clone(), custom_agent_id: self.custom_agent_id.clone(), status: self.status.map(|s| s.to_string()), pending_confirmations: 0, diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index 24a66a4ea..c20b3d7cf 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -13,12 +13,14 @@ use aionui_ai_agent::{AgentError, IWorkerTaskManager, WorkerTaskManagerImpl}; use aionui_api_types::{AcpBuildExtra, AddAgentRequest, CreateTeamRequest, TeamAgentInput, WebSocketMessage}; use aionui_common::{AgentKillReason, AgentType, PaginatedResult, ProviderWithModel}; use aionui_db::models::{ - AgentMetadataRow, ConversationRow, MessageRow, UpdateAgentAvailabilitySnapshotParams, UpdateAgentHandshakeParams, - UpsertAgentMetadataParams, + AgentMetadataRow, AssistantDefinitionRow, AssistantOverlayRow, ConversationRow, MessageRow, + UpdateAgentAvailabilitySnapshotParams, UpdateAgentHandshakeParams, UpsertAgentMetadataParams, + UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, }; use aionui_db::{ - ConversationFilters, ConversationRowUpdate, DbError, IAgentMetadataRepository, IConversationRepository, - IProviderRepository, ITeamRepository, MessageRowUpdate, MessageSearchRow, SortOrder, + ConversationFilters, ConversationRowUpdate, DbError, IAgentMetadataRepository, IAssistantDefinitionRepository, + IAssistantOverlayRepository, IConversationRepository, IProviderRepository, ITeamRepository, MessageRowUpdate, + MessageSearchRow, SortOrder, }; use aionui_realtime::EventBroadcaster; @@ -1021,6 +1023,60 @@ impl IProviderRepository for EmptyProviderRepo { } } +struct EmptyAssistantDefinitionRepo; + +#[async_trait::async_trait] +impl IAssistantDefinitionRepository for EmptyAssistantDefinitionRepo { + async fn list(&self) -> Result, DbError> { + Ok(vec![]) + } + + async fn get_by_key(&self, _assistant_key: &str) -> Result, DbError> { + Ok(None) + } + + async fn get_by_definition_id(&self, _definition_id: &str) -> Result, DbError> { + Ok(None) + } + + async fn get_by_source_ref( + &self, + _source: &str, + _source_ref: &str, + ) -> Result, DbError> { + Ok(None) + } + + async fn upsert(&self, _params: &UpsertAssistantDefinitionParams<'_>) -> Result { + Err(DbError::Init("not implemented".into())) + } + + async fn soft_delete(&self, _definition_id: &str, _deleted_at: i64) -> Result { + Ok(false) + } +} + +struct EmptyAssistantOverlayRepo; + +#[async_trait::async_trait] +impl IAssistantOverlayRepository for EmptyAssistantOverlayRepo { + async fn get(&self, _definition_id: &str) -> Result, DbError> { + Ok(None) + } + + async fn list(&self) -> Result, DbError> { + Ok(vec![]) + } + + async fn upsert(&self, _params: &UpsertAssistantOverlayParams<'_>) -> Result { + Err(DbError::Init("not implemented".into())) + } + + async fn delete(&self, _definition_id: &str) -> Result { + Ok(false) + } +} + fn setup_with_factory(factory: AgentFactory) -> (Arc, Arc) { setup_with_factory_and_metadata(factory, Arc::new(StubAgentMetadataRepo::empty())) } @@ -1070,6 +1126,8 @@ fn setup_with_factory_metadata_team_repo_and_conversation_repo( let svc = TeamSessionService::new( team_repo_dyn, agent_metadata_repo, + Arc::new(EmptyAssistantDefinitionRepo), + Arc::new(EmptyAssistantOverlayRepo), provider_repo, conversation_port, projection_store, @@ -1107,6 +1165,8 @@ fn setup_with_ports_team_repo_and_conversation_repo( let svc = TeamSessionService::new( team_repo_dyn, agent_metadata_repo, + Arc::new(EmptyAssistantDefinitionRepo), + Arc::new(EmptyAssistantOverlayRepo), provider_repo, conversation_port, projection_store, @@ -1141,6 +1201,8 @@ fn setup_with_recording_broadcaster() -> (Arc, Arc AgentMetadata available_models: None, available_commands: None, sort_order: 0, + last_check_status: None, + last_check_kind: None, + last_check_error_code: None, + last_check_error_message: None, + last_check_guidance: None, + last_check_latency_ms: None, + last_check_at: None, + last_success_at: None, + last_failure_at: None, created_at: 0, updated_at: 0, } @@ -1198,6 +1269,7 @@ fn two_agent_input() -> Vec { role: "lead".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, conversation_id: None, }, @@ -1206,6 +1278,7 @@ fn two_agent_input() -> Vec { role: "teammate".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, conversation_id: None, }, @@ -1342,6 +1415,7 @@ async fn tc_create_team_uses_custom_agent_id_icon_lookup() { role: "lead".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: Some("2d23ff1c".into()), conversation_id: None, }], @@ -1378,6 +1452,7 @@ async fn tc_create_team_carries_assistant_identity_into_lead_conversation_extra( role: "lead".into(), backend: "claude".into(), model: "claude".into(), + assistant_id: Some("2d23ff1c".into()), custom_agent_id: Some("2d23ff1c".into()), conversation_id: None, }], @@ -1394,6 +1469,7 @@ async fn tc_create_team_carries_assistant_identity_into_lead_conversation_extra( .expect("lead conversation row"); let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap(); + assert_eq!(extra["assistant_id"], serde_json::json!("2d23ff1c")); assert_eq!(extra["custom_agent_id"], serde_json::json!("2d23ff1c")); assert_eq!(extra["preset_assistant_id"], serde_json::json!("2d23ff1c")); } @@ -1416,6 +1492,7 @@ async fn ta_add_agent_uses_model_fallback_for_acp_backend() { role: "lead".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, conversation_id: None, }], @@ -1434,6 +1511,7 @@ async fn ta_add_agent_uses_model_fallback_for_acp_backend() { role: "teammate".into(), backend: "acp".into(), model: "codex".into(), + assistant_id: None, custom_agent_id: None, }, ) @@ -1456,6 +1534,7 @@ async fn tc2_create_single_agent_team() { role: "lead".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, conversation_id: None, }], @@ -1483,6 +1562,7 @@ async fn tc4_first_agent_is_lead() { role: "teammate".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, conversation_id: None, }, @@ -1491,6 +1571,7 @@ async fn tc4_first_agent_is_lead() { role: "teammate".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, conversation_id: None, }, @@ -1622,6 +1703,7 @@ async fn tl_list_teams_includes_pending_confirmation_counts_without_rebuilding_t role: "lead".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, conversation_id: None, }], @@ -1794,6 +1876,7 @@ async fn aa1_add_agent_to_team() { role: "lead".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, conversation_id: None, }], @@ -1812,6 +1895,7 @@ async fn aa1_add_agent_to_team() { role: "teammate".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, }, ) @@ -1844,6 +1928,7 @@ async fn aa_add_agent_inherits_team_workspace() { role: "lead".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, conversation_id: None, }], @@ -1862,6 +1947,7 @@ async fn aa_add_agent_inherits_team_workspace() { role: "teammate".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, }, ) @@ -1890,6 +1976,7 @@ async fn add_agent_backfills_empty_team_workspace_from_leader_workspace() { role: "lead".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, conversation_id: None, }], @@ -1914,6 +2001,7 @@ async fn add_agent_backfills_empty_team_workspace_from_leader_workspace() { role: "teammate".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, }, ) @@ -1944,6 +2032,7 @@ async fn add_agent_uses_team_temp_workspace_when_team_and_leader_workspaces_are_ role: "lead".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, conversation_id: None, }], @@ -1970,6 +2059,7 @@ async fn add_agent_uses_team_temp_workspace_when_team_and_leader_workspaces_are_ role: "teammate".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, }, ) @@ -2005,6 +2095,7 @@ async fn add_agent_does_not_create_teammate_when_workspace_writeback_fails() { role: "lead".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, conversation_id: None, }], @@ -2027,6 +2118,7 @@ async fn add_agent_does_not_create_teammate_when_workspace_writeback_fails() { role: "teammate".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, }, ) @@ -2055,6 +2147,7 @@ async fn add_agent_continues_when_team_temp_leader_patch_fails() { role: "lead".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, conversation_id: None, }], @@ -2084,6 +2177,7 @@ async fn add_agent_continues_when_team_temp_leader_patch_fails() { role: "teammate".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, }, ) @@ -2147,6 +2241,7 @@ async fn provisioning_writes_typed_team_binding_for_create_and_add_agent() { role: "teammate".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, }, ) @@ -2182,6 +2277,7 @@ async fn aa4_add_agent_to_nonexistent_team() { role: "teammate".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, }, ) @@ -2839,6 +2935,7 @@ async fn w4_d23_concurrent_add_agent_preserves_every_insertion() { role: "lead".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, conversation_id: None, }], @@ -2860,6 +2957,7 @@ async fn w4_d23_concurrent_add_agent_preserves_every_insertion() { role: "teammate".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, }, ) @@ -2878,6 +2976,7 @@ async fn w4_d23_concurrent_add_agent_preserves_every_insertion() { role: "teammate".into(), backend: "acp".into(), model: "claude".into(), + assistant_id: None, custom_agent_id: None, }, ) From c176bb85942ecafeede25d9726c34dbcc4bc2602 Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 16 Jun 2026 23:02:15 +0800 Subject: [PATCH 011/135] refactor(cron): persist assistant identity in cron config --- crates/aionui-api-types/src/cron.rs | 7 +++++ crates/aionui-app/tests/cron_e2e.rs | 3 +- crates/aionui-cron/src/executor.rs | 46 +++++++++++++++++++++++++++++ crates/aionui-cron/src/service.rs | 6 ++++ crates/aionui-cron/src/types.rs | 4 +++ 5 files changed, 65 insertions(+), 1 deletion(-) diff --git a/crates/aionui-api-types/src/cron.rs b/crates/aionui-api-types/src/cron.rs index 595b1646a..16c018d27 100644 --- a/crates/aionui-api-types/src/cron.rs +++ b/crates/aionui-api-types/src/cron.rs @@ -45,6 +45,8 @@ pub struct CronAgentConfigDto { #[serde(default, skip_serializing_if = "Option::is_none")] pub is_preset: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub assistant_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub custom_agent_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub preset_agent_type: Option, @@ -374,6 +376,7 @@ mod tests { "name": "Claude Agent", "cli_path": "/usr/bin/claude", "is_preset": true, + "assistant_id": "assistant-1", "custom_agent_id": "agent-1", "preset_agent_type": "claude", "mode": "auto", @@ -386,6 +389,7 @@ mod tests { assert_eq!(c.name, "Claude Agent"); assert_eq!(c.cli_path.as_deref(), Some("/usr/bin/claude")); assert_eq!(c.is_preset, Some(true)); + assert_eq!(c.assistant_id.as_deref(), Some("assistant-1")); assert_eq!(c.custom_agent_id.as_deref(), Some("agent-1")); assert_eq!(c.model_id.as_deref(), Some("claude-sonnet-4-6")); assert_eq!(c.config_options.as_ref().unwrap()["key"], "value"); @@ -409,6 +413,7 @@ mod tests { name: "Test".into(), cli_path: None, is_preset: None, + assistant_id: None, custom_agent_id: None, preset_agent_type: None, mode: None, @@ -429,6 +434,7 @@ mod tests { name: "Agent".into(), cli_path: Some("/bin/x".into()), is_preset: Some(false), + assistant_id: Some("assistant-1".into()), custom_agent_id: Some("c1".into()), preset_agent_type: None, mode: Some("plan".into()), @@ -472,6 +478,7 @@ mod tests { name: "Claude".into(), cli_path: None, is_preset: None, + assistant_id: None, custom_agent_id: None, preset_agent_type: None, mode: None, diff --git a/crates/aionui-app/tests/cron_e2e.rs b/crates/aionui-app/tests/cron_e2e.rs index 5a758c32c..cab347b3b 100644 --- a/crates/aionui-app/tests/cron_e2e.rs +++ b/crates/aionui-app/tests/cron_e2e.rs @@ -681,7 +681,7 @@ async fn rn1c_run_now_new_conversation_preset_assistant_uses_fixed_assistant_mcp "backend": "codex", "name": "Cron MCP Assistant", "is_preset": true, - "custom_agent_id": "u-fixed-mcp", + "assistant_id": "u-fixed-mcp", "preset_agent_type": "codex" } }), @@ -730,6 +730,7 @@ async fn rn1c_run_now_new_conversation_preset_assistant_uses_fixed_assistant_mcp .expect("conversation should exist"); let extra: serde_json::Value = serde_json::from_str(&conversation.extra).expect("conversation extra should be valid json"); + assert_eq!(extra["assistant_id"], "u-fixed-mcp"); assert_eq!(extra["preset_assistant_id"], "u-fixed-mcp"); assert_eq!(extra["mcp_server_ids"], json!([fixed_mcp.id])); assert_eq!(extra["mcp_servers"], json!(["fixed-mcp"])); diff --git a/crates/aionui-cron/src/executor.rs b/crates/aionui-cron/src/executor.rs index 34e0594c8..f72164b9e 100644 --- a/crates/aionui-cron/src/executor.rs +++ b/crates/aionui-cron/src/executor.rs @@ -1054,6 +1054,12 @@ async fn build_task_extra(registry: &AgentRegistry, job: &CronJob, skills: &[Str if !config.name.is_empty() { extra.insert("agent_name".to_owned(), serde_json::Value::String(config.name.clone())); } + if let Some(assistant_id) = &config.assistant_id { + extra.insert( + "assistant_id".to_owned(), + serde_json::Value::String(assistant_id.clone()), + ); + } if let Some(custom_agent_id) = &config.custom_agent_id { extra.insert( "custom_agent_id".to_owned(), @@ -1124,6 +1130,18 @@ async fn build_conversation_extra( if !config.name.is_empty() { extra.insert("agent_name".to_owned(), serde_json::Value::String(config.name.clone())); } + if let Some(assistant_id) = &config.assistant_id { + extra.insert( + "assistant_id".to_owned(), + serde_json::Value::String(assistant_id.clone()), + ); + if config.is_preset.unwrap_or(false) { + extra.insert( + "preset_assistant_id".to_owned(), + serde_json::Value::String(assistant_id.clone()), + ); + } + } if let Some(custom_agent_id) = &config.custom_agent_id { extra.insert( "custom_agent_id".to_owned(), @@ -1256,6 +1274,7 @@ mod tests { name: "Claude".into(), cli_path: Some("/usr/bin/claude".into()), is_preset: None, + assistant_id: Some("assistant-sample".into()), custom_agent_id: None, preset_agent_type: None, mode: None, @@ -1542,6 +1561,7 @@ mod tests { name: "OpenAI".into(), cli_path: None, is_preset: None, + assistant_id: None, custom_agent_id: None, preset_agent_type: None, mode: None, @@ -1565,6 +1585,7 @@ mod tests { name: "OpenAI".into(), cli_path: None, is_preset: None, + assistant_id: None, custom_agent_id: None, preset_agent_type: None, mode: None, @@ -1600,6 +1621,7 @@ mod tests { name: "Bogus".into(), cli_path: None, is_preset: None, + assistant_id: None, custom_agent_id: None, preset_agent_type: None, mode: None, @@ -1630,6 +1652,7 @@ mod tests { assert_eq!(extra["backend"], "acp"); assert_eq!(extra["cli_path"], "/usr/bin/claude"); assert_eq!(extra["agent_name"], "Claude"); + assert_eq!(extra["assistant_id"], "assistant-sample"); assert_eq!(extra["skills"], serde_json::json!(["cron-cron_test1"])); } @@ -1693,6 +1716,22 @@ mod tests { assert_eq!(extra["preset_enabled_skills"], serde_json::json!(["cron-cron_test1"])); } + #[tokio::test] + async fn build_conversation_extra_writes_assistant_identity_for_preset_jobs() { + let registry = hydrated_registry().await; + let mut job = sample_job(); + let config = job.agent_config.as_mut().expect("sample job should carry config"); + config.assistant_id = Some("assistant-preset".into()); + config.is_preset = Some(true); + config.custom_agent_id = None; + + let extra = build_conversation_extra(®istry, &job, None).await; + + assert_eq!(extra["assistant_id"], "assistant-preset"); + assert_eq!(extra["preset_assistant_id"], "assistant-preset"); + assert!(extra.get("custom_agent_id").is_none()); + } + #[tokio::test] async fn build_conversation_extra_preserves_agent_workspace() { let registry = hydrated_registry().await; @@ -2982,6 +3021,13 @@ mod tests { ) -> Result, aionui_db::DbError> { Ok(None) } + async fn update_availability_snapshot( + &self, + _id: &str, + _params: &aionui_db::models::UpdateAgentAvailabilitySnapshotParams<'_>, + ) -> Result, aionui_db::DbError> { + Ok(None) + } async fn set_enabled(&self, _id: &str, _enabled: bool) -> Result { Ok(false) } diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index bdead100c..75aa3d98b 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -82,6 +82,7 @@ impl CronService { name: c.name, cli_path: c.cli_path, is_preset: c.is_preset, + assistant_id: c.assistant_id, custom_agent_id: c.custom_agent_id, preset_agent_type: c.preset_agent_type, mode: c.mode, @@ -166,6 +167,7 @@ impl CronService { name: config_dto.name.clone(), cli_path: config_dto.cli_path.clone(), is_preset: config_dto.is_preset, + assistant_id: config_dto.assistant_id.clone(), custom_agent_id: config_dto.custom_agent_id.clone(), preset_agent_type: config_dto.preset_agent_type.clone(), mode: config_dto.mode.clone(), @@ -1056,6 +1058,7 @@ fn build_agent_config_from_conversation( }; let preset_assistant_id = get_string(&extra, &["preset_assistant_id", "presetAssistantId"]); + let assistant_id = get_string(&extra, &["assistant_id", "assistantId"]).or_else(|| preset_assistant_id.clone()); let custom_agent_id = get_string(&extra, &["custom_agent_id", "customAgentId"]).or(preset_assistant_id.clone()); let is_preset = preset_assistant_id.as_ref().map(|_| true); let preset_agent_type = if preset_assistant_id.is_some() { @@ -1082,6 +1085,7 @@ fn build_agent_config_from_conversation( .map(ToOwned::to_owned) }), is_preset, + assistant_id, custom_agent_id, preset_agent_type, mode: Some(full_auto_mode), @@ -1211,6 +1215,7 @@ fn build_update_params(job: &CronJob, req: &UpdateCronJobRequest) -> UpdateCronJ name: c.name.clone(), cli_path: c.cli_path.clone(), is_preset: c.is_preset, + assistant_id: c.assistant_id.clone(), custom_agent_id: c.custom_agent_id.clone(), preset_agent_type: c.preset_agent_type.clone(), mode: c.mode.clone(), @@ -1333,6 +1338,7 @@ mod tests { name: "provider".into(), cli_path: None, is_preset: None, + assistant_id: None, custom_agent_id: None, preset_agent_type: None, mode: None, diff --git a/crates/aionui-cron/src/types.rs b/crates/aionui-cron/src/types.rs index 8b4611ebb..61b55c3e0 100644 --- a/crates/aionui-cron/src/types.rs +++ b/crates/aionui-cron/src/types.rs @@ -132,6 +132,8 @@ pub struct CronAgentConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub is_preset: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub assistant_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub custom_agent_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub preset_agent_type: Option, @@ -334,6 +336,7 @@ pub fn cron_job_to_response(job: &CronJob) -> CronJobResponse { name: c.name.clone(), cli_path: c.cli_path.clone(), is_preset: c.is_preset, + assistant_id: c.assistant_id.clone(), custom_agent_id: c.custom_agent_id.clone(), preset_agent_type: c.preset_agent_type.clone(), mode: c.mode.clone(), @@ -587,6 +590,7 @@ mod tests { name: "Claude".into(), cli_path: None, is_preset: None, + assistant_id: None, custom_agent_id: None, preset_agent_type: None, mode: None, From 940e287939961273821f9d8fa2241790e0f7e01d Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 00:52:56 +0800 Subject: [PATCH 012/135] feat(agent): probe managed builtin acp health --- .../src/protocol/custom_agent_probe.rs | 8 +- .../src/services/availability/mod.rs | 174 +++++++++++++++++- 2 files changed, 176 insertions(+), 6 deletions(-) diff --git a/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs b/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs index f202d7cff..3e1002962 100644 --- a/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs +++ b/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs @@ -18,7 +18,7 @@ use std::time::Duration; use aionui_api_types::TryConnectCustomAgentResponse; use aionui_common::{CommandSpec, EnvVar}; -use aionui_runtime::{NodeRuntimeProgressReporter, ensure_runtime_command_with_reporter}; +use aionui_runtime::{NodeRuntimeProgressReporter, ResolvedCommand, ensure_runtime_command_with_reporter}; use tokio::sync::{broadcast, mpsc}; use tracing::debug; @@ -69,7 +69,7 @@ fn first_token(command: &str) -> &str { } async fn acp_initialize( - resolved: aionui_runtime::ResolvedCommand, + resolved: ResolvedCommand, args: &[String], env: &HashMap, data_dir: &Path, @@ -100,6 +100,10 @@ async fn acp_initialize( cwd: Some(std::env::temp_dir().to_string_lossy().into_owned()), }; + acp_initialize_command_spec(spec, data_dir).await +} + +pub(crate) async fn acp_initialize_command_spec(spec: CommandSpec, data_dir: &Path) -> Result<(), String> { let proc = CliAgentProcess::spawn_for_sdk(spec, data_dir) .await .map_err(|e| format!("spawn failed: {e}"))?; diff --git a/crates/aionui-ai-agent/src/services/availability/mod.rs b/crates/aionui-ai-agent/src/services/availability/mod.rs index 9fb8774ab..c3d70d82a 100644 --- a/crates/aionui-ai-agent/src/services/availability/mod.rs +++ b/crates/aionui-ai-agent/src/services/availability/mod.rs @@ -7,10 +7,15 @@ use std::sync::{ use std::time::Instant; use aionui_api_types::{ - AgentManagementRow, AgentMetadata, AgentSnapshotCheckKind, AgentSnapshotCheckStatus, TryConnectCustomAgentResponse, + AgentManagementRow, AgentMetadata, AgentSnapshotCheckKind, AgentSnapshotCheckStatus, AgentSource, + TryConnectCustomAgentResponse, }; use aionui_common::now_ms; +use aionui_common::{CommandSpec, EnvVar}; use aionui_db::UpdateAgentAvailabilitySnapshotParams; +use aionui_runtime::{ + ManagedAcpToolId, ensure_managed_acp_tool_with_reporter, ensure_node_runtime_with_reporter, resolve_command_path, +}; use tokio::time::{Duration, sleep}; use crate::error::AgentError; @@ -183,7 +188,24 @@ async fn run_probe( let started_at = now_ms(); let start = Instant::now(); - let (status, error_code, error_message) = if let Some(command) = meta.command.as_deref() { + let (status, error_code, error_message) = if meta.agent_source == AgentSource::Builtin + && let Some(backend) = meta.backend.as_deref() + && let Some(tool) = ManagedAcpToolId::from_backend(backend) + { + match try_connect_builtin_managed_agent(meta, data_dir, tool).await { + TryConnectCustomAgentResponse::Success => (AgentSnapshotCheckStatus::Available, None, None), + TryConnectCustomAgentResponse::FailCli { error } => ( + AgentSnapshotCheckStatus::Unavailable, + Some("command_not_found".to_owned()), + Some(error), + ), + TryConnectCustomAgentResponse::FailAcp { error } => ( + AgentSnapshotCheckStatus::Unavailable, + Some("acp_init_failed".to_owned()), + Some(error), + ), + } + } else if let Some(command) = meta.command.as_deref() { let env: HashMap = meta .env .iter() @@ -238,6 +260,76 @@ async fn run_probe( } } +async fn try_connect_builtin_managed_agent( + meta: &AgentMetadata, + data_dir: &std::path::Path, + tool: ManagedAcpToolId, +) -> TryConnectCustomAgentResponse { + if let Some(primary) = meta.agent_source_info.binary_name.as_deref() + && resolve_command_path(primary).is_none() + { + return TryConnectCustomAgentResponse::FailCli { + error: format!("`{primary}` not found on PATH"), + }; + } + + let node_runtime = match ensure_node_runtime_with_reporter(None).await { + Ok(runtime) => runtime, + Err(error) => { + return TryConnectCustomAgentResponse::FailCli { + error: error.to_string(), + }; + } + }; + + let managed_tool = match ensure_managed_acp_tool_with_reporter(tool, None).await { + Ok(tool) => tool, + Err(error) => { + return TryConnectCustomAgentResponse::FailCli { + error: error.to_string(), + }; + } + }; + + let resolved = managed_tool.command(&node_runtime); + let mut env: Vec = meta + .env + .iter() + .map(|entry| EnvVar { + name: entry.name.clone(), + value: entry.value.clone(), + }) + .collect(); + env.extend(resolved.env.iter().map(|(name, value)| EnvVar { + name: name.to_string_lossy().into_owned(), + value: value.to_string_lossy().into_owned(), + })); + + let spec = CommandSpec { + command: resolved.program, + args: resolved + .args_prefix + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(), + env, + cwd: Some(std::env::temp_dir().to_string_lossy().into_owned()), + }; + + match tokio::time::timeout( + Duration::from_secs(35), + custom_agent_probe::acp_initialize_command_spec(spec, data_dir), + ) + .await + { + Ok(Ok(())) => TryConnectCustomAgentResponse::Success, + Ok(Err(error)) => TryConnectCustomAgentResponse::FailAcp { error }, + Err(_) => TryConnectCustomAgentResponse::FailAcp { + error: "ACP initialize did not complete within 35s".to_owned(), + }, + } +} + #[async_trait::async_trait] impl AgentAvailabilityFeedbackPort for AgentAvailabilityService { async fn record_session_failure(&self, agent_id: &str, code: &str, message: &str) -> Result<(), AgentError> { @@ -249,13 +341,17 @@ impl AgentAvailabilityFeedbackPort for AgentAvailabilityService { mod tests { use std::sync::{Arc, atomic::AtomicBool}; - use aionui_api_types::{AgentManagementStatus, AgentSnapshotCheckKind, AgentSnapshotCheckStatus}; + use aionui_api_types::{ + AgentHandshake, AgentManagementStatus, AgentMetadata, AgentSnapshotCheckKind, AgentSnapshotCheckStatus, + AgentSource, AgentSourceInfo, BehaviorPolicy, + }; + use aionui_common::AgentType; use aionui_db::{ IAgentMetadataRepository, SqliteAgentMetadataRepository, UpsertAgentMetadataParams, init_database_memory, }; use tokio::time::Duration; - use super::AgentAvailabilityService; + use super::{AgentAvailabilityService, run_probe}; use crate::registry::AgentRegistry; #[tokio::test] @@ -390,4 +486,74 @@ mod tests { assert!(row.last_check_status.is_some()); assert!(row.last_check_at.is_some()); } + + #[tokio::test] + async fn managed_builtin_probe_checks_primary_binary_before_running_bridge_command() { + let db = init_database_memory().await.unwrap(); + let repo: Arc = Arc::new(SqliteAgentMetadataRepository::new(db.pool().clone())); + let registry = AgentRegistry::new(repo); + registry.hydrate().await.unwrap(); + + let meta = AgentMetadata { + id: "agent-managed-builtin".into(), + icon: None, + name: "Claude Code".into(), + name_i18n: None, + description: None, + description_i18n: None, + backend: Some("claude".into()), + agent_type: AgentType::Acp, + agent_source: AgentSource::Builtin, + agent_source_info: AgentSourceInfo { + binary_name: Some("definitely-missing-claude-cli".into()), + bridge_binary: Some("bun".into()), + hub_package_id: None, + version: None, + }, + enabled: true, + available: true, + command: Some("bun".into()), + resolved_command: None, + args: vec![ + "x".into(), + "--bun".into(), + "@agentclientprotocol/claude-agent-acp@0.39.0".into(), + ], + env: vec![], + native_skills_dirs: Some(vec![".claude/skills".into()]), + behavior_policy: BehaviorPolicy::default(), + yolo_id: Some("bypassPermissions".into()), + sort_order: 3100, + team_capable: true, + last_check_status: None, + last_check_kind: None, + last_check_error_code: None, + last_check_error_message: None, + last_check_guidance: None, + last_check_latency_ms: None, + last_check_at: None, + last_success_at: None, + last_failure_at: None, + handshake: AgentHandshake::default(), + }; + + let snapshot = run_probe( + ®istry, + &meta, + std::env::temp_dir().as_path(), + AgentSnapshotCheckKind::Manual, + ) + .await; + + assert_eq!(snapshot.status, "unavailable"); + assert_eq!(snapshot.error_code.as_deref(), Some("command_not_found")); + assert!( + snapshot + .error_message + .as_deref() + .is_some_and(|message| message.contains("definitely-missing-claude-cli")), + "expected missing primary binary message, got {:?}", + snapshot.error_message + ); + } } From f14083f5f1e6c854be9c341045fb40da1f426b80 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 01:38:59 +0800 Subject: [PATCH 013/135] refactor(agent): drop legacy backend health check route --- .../src/protocol/cli_detect.rs | 19 ++++---- crates/aionui-ai-agent/src/routes/agent.rs | 22 ++------- crates/aionui-ai-agent/src/services/agent.rs | 9 +--- crates/aionui-api-types/src/acp.rs | 41 ---------------- crates/aionui-api-types/src/lib.rs | 10 ++-- crates/aionui-app/tests/acp_e2e.rs | 47 +------------------ 6 files changed, 21 insertions(+), 127 deletions(-) diff --git a/crates/aionui-ai-agent/src/protocol/cli_detect.rs b/crates/aionui-ai-agent/src/protocol/cli_detect.rs index a4644e65e..c3426be3c 100644 --- a/crates/aionui-ai-agent/src/protocol/cli_detect.rs +++ b/crates/aionui-ai-agent/src/protocol/cli_detect.rs @@ -2,30 +2,33 @@ use std::sync::Arc; use std::time::Instant; use crate::registry::AgentRegistry; -use aionui_api_types::{AcpHealthCheckResponse, AgentMetadata}; +use aionui_api_types::AgentMetadata; use aionui_runtime::resolve_command_path; +pub(crate) struct CliHealthCheckResult { + pub available: bool, + pub error: Option, +} + /// Perform a health check for an ACP backend. /// -/// Checks CLI availability and measures detection latency. -pub(crate) async fn health_check(registry: &Arc, backend: &str) -> AcpHealthCheckResponse { +/// Checks CLI availability and returns an availability/error pair. +pub(crate) async fn health_check(registry: &Arc, backend: &str) -> CliHealthCheckResult { let start = Instant::now(); let Some(meta) = registry.find_builtin_by_backend(backend).await else { - return AcpHealthCheckResponse { + return CliHealthCheckResult { available: false, - latency: None, error: Some(format!("No agent_metadata row for backend '{backend}'")), }; }; let path = probe_command(&meta); - let latency_ms = start.elapsed().as_millis() as u64; + let _latency_ms = start.elapsed().as_millis() as u64; let available = path.is_some(); - AcpHealthCheckResponse { + CliHealthCheckResult { available, - latency: Some(latency_ms), error: if available { None } else { diff --git a/crates/aionui-ai-agent/src/routes/agent.rs b/crates/aionui-ai-agent/src/routes/agent.rs index e0407a758..ebc9b4f6b 100644 --- a/crates/aionui-ai-agent/src/routes/agent.rs +++ b/crates/aionui-ai-agent/src/routes/agent.rs @@ -14,9 +14,9 @@ use axum::extract::{Extension, Json, Path, State}; use axum::routing::{get, patch, post, put}; use aionui_api_types::{ - AcpHealthCheckRequest, AcpHealthCheckResponse, AgentManagementRow, AgentMetadata, ApiResponse, - CustomAgentUpsertRequest, DeleteCustomAgentResponse, ProviderHealthCheckRequest, ProviderHealthCheckResponse, - SetEnabledRequest, TryConnectCustomAgentRequest, TryConnectCustomAgentResponse, + AgentManagementRow, AgentMetadata, ApiResponse, CustomAgentUpsertRequest, DeleteCustomAgentResponse, + ProviderHealthCheckRequest, ProviderHealthCheckResponse, SetEnabledRequest, TryConnectCustomAgentRequest, + TryConnectCustomAgentResponse, }; use aionui_auth::CurrentUser; use aionui_common::ApiError; @@ -29,7 +29,6 @@ pub fn agent_routes(state: AgentRouterState) -> Router { .route("/api/agents", get(list_agents)) .route("/api/agents/management", get(list_management_agents)) .route("/api/agents/refresh", post(refresh_agents)) - .route("/api/agents/health-check", post(health_check)) .route("/api/agents/{id}/health-check", post(health_check_by_id)) .route("/api/agents/provider-health-check", post(provider_health_check)) .route("/api/agents/{id}/enabled", patch(set_agent_enabled)) @@ -70,21 +69,6 @@ async fn list_management_agents( ))) } -async fn health_check( - State(state): State, - Extension(_user): Extension, - body: Result, JsonRejection>, -) -> Result>, ApiError> { - let Json(req) = body.map_err(ApiError::from)?; - Ok(Json(ApiResponse::ok( - state - .service - .acp_health_check(req) - .await - .map_err(agent_error_to_api_error)?, - ))) -} - async fn health_check_by_id( 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 0922c6d7c..32c5ba704 100644 --- a/crates/aionui-ai-agent/src/services/agent.rs +++ b/crates/aionui-ai-agent/src/services/agent.rs @@ -15,10 +15,7 @@ use std::path::PathBuf; use std::sync::Arc; -use aionui_api_types::{ - AcpHealthCheckRequest, AcpHealthCheckResponse, AgentManagementRow, AgentMetadata, ProviderHealthCheckRequest, - ProviderHealthCheckResponse, -}; +use aionui_api_types::{AgentManagementRow, AgentMetadata, ProviderHealthCheckRequest, ProviderHealthCheckResponse}; use aionui_db::IProviderRepository; use aionui_realtime::EventBroadcaster; @@ -102,10 +99,6 @@ impl AgentService { .collect()) } - pub async fn acp_health_check(&self, req: AcpHealthCheckRequest) -> Result { - Ok(crate::protocol::cli_detect::health_check(&self.registry, &req.backend).await) - } - pub async fn list_management_agents(&self) -> Result, AgentError> { Ok(self.availability.list_management_rows().await) } diff --git a/crates/aionui-api-types/src/acp.rs b/crates/aionui-api-types/src/acp.rs index c222ef8ef..6f00d23e3 100644 --- a/crates/aionui-api-types/src/acp.rs +++ b/crates/aionui-api-types/src/acp.rs @@ -19,22 +19,6 @@ pub struct DetectCliResponse { pub path: Option, } -/// Request body for ACP health check. -#[derive(Debug, Deserialize)] -pub struct AcpHealthCheckRequest { - pub backend: String, -} - -/// Response for ACP health check. -#[derive(Debug, Serialize)] -pub struct AcpHealthCheckResponse { - pub available: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub latency: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - /// Response for ACP environment variables. #[derive(Debug, Serialize)] pub struct AcpEnvResponse { @@ -234,31 +218,6 @@ mod tests { assert!(json.get("path").is_none()); } - #[test] - fn health_check_response_available() { - let resp = AcpHealthCheckResponse { - available: true, - latency: Some(120), - error: None, - }; - let json = serde_json::to_value(&resp).unwrap(); - assert_eq!(json["available"], true); - assert_eq!(json["latency"], 120); - assert!(json.get("error").is_none()); - } - - #[test] - fn health_check_response_unavailable() { - let resp = AcpHealthCheckResponse { - available: false, - latency: None, - error: Some("CLI not found".into()), - }; - let json = serde_json::to_value(&resp).unwrap(); - assert_eq!(json["available"], false); - assert_eq!(json["error"], "CLI not found"); - } - #[test] fn set_mode_request_serde() { let json = json!({ "mode": "code" }); diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index 9c94b7661..fa34c45d4 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -31,11 +31,11 @@ mod team_mcp; mod websocket; pub use acp::{ - AcpConfigOptionDto, AcpConfigSelectOptionDto, AcpEnvResponse, AcpHealthCheckRequest, AcpHealthCheckResponse, - AgentModeResponse, ConfigOptionConfirmation, DetectCliRequest, DetectCliResponse, GetConfigOptionsResponse, - GetModelInfoResponse, ModelInfoEntry, ModelInfoPayload, ProbeModelRequest, SetConfigOptionRequest, - SetConfigOptionResponse, SetModeRequest, SetModelRequest, SideQuestionRequest, SideQuestionResponse, - TryConnectCustomAgentRequest, TryConnectCustomAgentResponse, WorkspaceBrowseQuery, WorkspaceEntry, + AcpConfigOptionDto, AcpConfigSelectOptionDto, AcpEnvResponse, AgentModeResponse, ConfigOptionConfirmation, + DetectCliRequest, DetectCliResponse, GetConfigOptionsResponse, GetModelInfoResponse, ModelInfoEntry, + ModelInfoPayload, ProbeModelRequest, SetConfigOptionRequest, SetConfigOptionResponse, SetModeRequest, + SetModelRequest, SideQuestionRequest, SideQuestionResponse, TryConnectCustomAgentRequest, + TryConnectCustomAgentResponse, WorkspaceBrowseQuery, WorkspaceEntry, }; pub use acp_prompt_hook::AcpPromptHookWarningPayload; pub use agent_build_extra::{ diff --git a/crates/aionui-app/tests/acp_e2e.rs b/crates/aionui-app/tests/acp_e2e.rs index d2f61fb45..f5a915d53 100644 --- a/crates/aionui-app/tests/acp_e2e.rs +++ b/crates/aionui-app/tests/acp_e2e.rs @@ -1,6 +1,6 @@ //! E2E integration tests for ACP management routes. //! -//! Tests cover: agents list, agents/refresh, agents/test, health-check, +//! Tests cover: agents list, agents/refresh, agents/test, //! and session-bound routes (mode/model). mod common; @@ -71,51 +71,6 @@ async fn test_custom_agent_nonexistent_command() { assert_eq!(json["data"]["step"], "fail_cli"); } -#[tokio::test] -async fn health_check_returns_status() { - let (mut app, services) = build_app().await; - let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; - - let req = json_with_token( - "POST", - "/api/agents/health-check", - json!({ "backend": "claude" }), - &token, - &csrf, - ); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - - let body = body_json(resp).await; - assert_eq!(body["success"], true); - // available is a boolean - assert!(body["data"]["available"].is_boolean()); - // latency should be present - assert!(body["data"]["latency"].is_number()); -} - -#[tokio::test] -async fn health_check_unknown_backend_reports_unavailable() { - let (mut app, services) = build_app().await; - let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; - - // Same rationale as `detect_cli_unknown_backend_returns_null_path`: - // unknown backends are valid at the request layer and surface as - // `available: false` with an error string. - let req = json_with_token( - "POST", - "/api/agents/health-check", - json!({ "backend": "iFlow" }), - &token, - &csrf, - ); - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = body_json(resp).await; - assert_eq!(body["success"], true); - assert_eq!(body["data"]["available"], false); -} - #[tokio::test] async fn management_list_includes_missing_custom_agents() { let (mut app, services) = build_app().await; From 4bdab9b5e50488af849cddc24220059b6f1ab938 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 03:07:01 +0800 Subject: [PATCH 014/135] refactor(cron): create conversations with assistant identity --- crates/aionui-cron/src/executor.rs | 70 +++++++++++++++++------------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/crates/aionui-cron/src/executor.rs b/crates/aionui-cron/src/executor.rs index f72164b9e..1e2781c46 100644 --- a/crates/aionui-cron/src/executor.rs +++ b/crates/aionui-cron/src/executor.rs @@ -5,7 +5,7 @@ use std::time::Duration; use aionui_ai_agent::task_manager::IWorkerTaskManager; use aionui_ai_agent::types::SendMessageData; use aionui_ai_agent::{AgentRegistry, AgentStreamEvent}; -use aionui_api_types::{CreateConversationRequest, SendMessageRequest}; +use aionui_api_types::{AssistantConversationRequest, CreateConversationRequest, SendMessageRequest}; use aionui_common::{ AgentType, ProviderWithModel, WorkspacePathValidationError, now_ms, validate_workspace_path_availability, }; @@ -435,7 +435,7 @@ impl JobExecutor { r#type: agent_type, name: Some(job.name.clone()), model, - assistant: None, + assistant: build_assistant_request(job), source: None, channel_chat_id: None, extra, @@ -1095,6 +1095,28 @@ fn build_prompt(job: &CronJob, saved_skill: Option<&SavedSkillContext>) -> Strin } } +fn build_assistant_request(job: &CronJob) -> Option { + let config = job.agent_config.as_ref()?; + let assistant_id = config + .assistant_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + config + .custom_agent_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + })?; + + Some(AssistantConversationRequest { + id: assistant_id, + locale: None, + conversation_overrides: None, + }) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SavedSkillContext { name: String, @@ -1130,30 +1152,6 @@ async fn build_conversation_extra( if !config.name.is_empty() { extra.insert("agent_name".to_owned(), serde_json::Value::String(config.name.clone())); } - if let Some(assistant_id) = &config.assistant_id { - extra.insert( - "assistant_id".to_owned(), - serde_json::Value::String(assistant_id.clone()), - ); - if config.is_preset.unwrap_or(false) { - extra.insert( - "preset_assistant_id".to_owned(), - serde_json::Value::String(assistant_id.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()), - ); - } - } if let Some(mode) = &config.mode { extra.insert("session_mode".to_owned(), serde_json::Value::String(mode.clone())); } @@ -1717,7 +1715,7 @@ mod tests { } #[tokio::test] - async fn build_conversation_extra_writes_assistant_identity_for_preset_jobs() { + async fn build_conversation_extra_omits_legacy_assistant_identity_fields_for_new_conversations() { let registry = hydrated_registry().await; let mut job = sample_job(); let config = job.agent_config.as_mut().expect("sample job should carry config"); @@ -1727,11 +1725,25 @@ mod tests { let extra = build_conversation_extra(®istry, &job, None).await; - assert_eq!(extra["assistant_id"], "assistant-preset"); - assert_eq!(extra["preset_assistant_id"], "assistant-preset"); + assert!(extra.get("assistant_id").is_none()); + assert!(extra.get("preset_assistant_id").is_none()); assert!(extra.get("custom_agent_id").is_none()); } + #[test] + fn build_assistant_request_uses_legacy_custom_agent_id_when_assistant_id_missing() { + let mut job = sample_job(); + let config = job.agent_config.as_mut().expect("sample job should carry config"); + config.assistant_id = None; + config.custom_agent_id = Some("custom-assistant".into()); + + let assistant = build_assistant_request(&job).expect("legacy custom assistant should map to request"); + + assert_eq!(assistant.id, "custom-assistant"); + assert!(assistant.locale.is_none()); + assert!(assistant.conversation_overrides.is_none()); + } + #[tokio::test] async fn build_conversation_extra_preserves_agent_workspace() { let registry = hydrated_registry().await; From 72635153d43b7bacdf4f727c356f6c43c878bdf2 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 03:09:47 +0800 Subject: [PATCH 015/135] refactor(channel): normalize assistant bindings on write --- crates/aionui-channel/src/channel_settings.rs | 103 +++++++++++++++--- 1 file changed, 85 insertions(+), 18 deletions(-) diff --git a/crates/aionui-channel/src/channel_settings.rs b/crates/aionui-channel/src/channel_settings.rs index 69c089abb..cee924d50 100644 --- a/crates/aionui-channel/src/channel_settings.rs +++ b/crates/aionui-channel/src/channel_settings.rs @@ -88,11 +88,7 @@ impl ChannelSettingsService { } if let Some(at) = setting.agent_type.as_deref() { - let backend = if at == "acp" { - setting.backend.clone() - } else { - None - }; + let backend = if at == "acp" { setting.backend.clone() } else { None }; debug!(platform = %platform, agent_type = %at, backend = ?backend, "resolved channel agent config (new format)"); @@ -181,7 +177,8 @@ impl ChannelSettingsService { platform: PluginType, assistant: &ChannelAssistantSetting, ) -> Result<(), ChannelError> { - let payload = serde_json::to_string(assistant).map_err(ChannelError::Json)?; + let normalized = normalize_channel_assistant_setting_for_write(assistant); + let payload = serde_json::to_string(&normalized).map_err(ChannelError::Json)?; let key = agent_key(platform); self.pref_repo.upsert_batch(&[(&key, payload.as_str())]).await?; Ok(()) @@ -261,6 +258,29 @@ fn parse_channel_assistant_setting(value: &str) -> Option ChannelAssistantSetting { + let assistant_id = assistant + .assistant_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + assistant + .custom_agent_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + }); + + ChannelAssistantSetting { + assistant_id, + custom_agent_id: None, + backend: None, + agent_type: None, + name: assistant.name.clone(), + } +} + fn parse_channel_model_setting(value: &str) -> Option { let parsed: serde_json::Value = serde_json::from_str(value).ok()?; let id = parsed["id"].as_str()?.to_owned(); @@ -392,11 +412,7 @@ mod tests { } async fn get_by_definition_id(&self, definition_id: &str) -> Result, DbError> { - Ok(self - .rows - .iter() - .find(|row| row.definition_id == definition_id) - .cloned()) + Ok(self.rows.iter().find(|row| row.definition_id == definition_id).cloned()) } async fn get_by_source_ref( @@ -430,11 +446,7 @@ mod tests { #[async_trait::async_trait] impl IAssistantOverlayRepository for MockAssistantOverlayRepo { async fn get(&self, definition_id: &str) -> Result, DbError> { - Ok(self - .rows - .iter() - .find(|row| row.definition_id == definition_id) - .cloned()) + Ok(self.rows.iter().find(|row| row.definition_id == definition_id).cloned()) } async fn list(&self) -> Result, DbError> { @@ -624,8 +636,7 @@ mod tests { let definition_repo: Arc = Arc::new(MockAssistantDefinitionRepo { rows: vec![make_definition("bare-claude", "claude")], }); - let overlay_repo: Arc = - Arc::new(MockAssistantOverlayRepo { rows: vec![] }); + let overlay_repo: Arc = Arc::new(MockAssistantOverlayRepo { rows: vec![] }); let svc = ChannelSettingsService::new(repo).with_assistant_repos(definition_repo, overlay_repo); let config = svc.get_agent_config(PluginType::Telegram).await.unwrap(); @@ -689,6 +700,62 @@ mod tests { assert!(config.is_none()); } + #[tokio::test] + async fn set_assistant_setting_persists_assistant_only_payload() { + let repo = Arc::new(MockPrefRepo::new()); + let svc = ChannelSettingsService::new(repo.clone()); + + svc.set_assistant_setting( + PluginType::Telegram, + &ChannelAssistantSetting { + assistant_id: Some("assistant-1".into()), + custom_agent_id: Some("legacy-custom".into()), + backend: Some("claude".into()), + agent_type: Some("acp".into()), + name: Some("Claude".into()), + }, + ) + .await + .unwrap(); + + let stored = repo.get_by_keys(&["assistant.telegram.agent"]).await.unwrap(); + let payload = serde_json::from_str::(&stored[0].value).unwrap(); + + assert_eq!(payload["assistant_id"], "assistant-1"); + assert_eq!(payload["name"], "Claude"); + assert!(payload.get("custom_agent_id").is_none()); + assert!(payload.get("backend").is_none()); + assert!(payload.get("agent_type").is_none()); + } + + #[tokio::test] + async fn set_assistant_setting_promotes_legacy_custom_agent_id() { + let repo = Arc::new(MockPrefRepo::new()); + let svc = ChannelSettingsService::new(repo.clone()); + + svc.set_assistant_setting( + PluginType::Lark, + &ChannelAssistantSetting { + assistant_id: None, + custom_agent_id: Some("legacy-custom".into()), + backend: Some("codex".into()), + agent_type: Some("acp".into()), + name: Some("Codex".into()), + }, + ) + .await + .unwrap(); + + let stored = repo.get_by_keys(&["assistant.lark.agent"]).await.unwrap(); + let payload = serde_json::from_str::(&stored[0].value).unwrap(); + + assert_eq!(payload["assistant_id"], "legacy-custom"); + assert_eq!(payload["name"], "Codex"); + assert!(payload.get("custom_agent_id").is_none()); + assert!(payload.get("backend").is_none()); + assert!(payload.get("agent_type").is_none()); + } + // ── resolved_model_to_provider ──────────────────────────────────── #[test] From 987f8f1613f636cd9b2892a7a68e7e286f32dab7 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 03:26:56 +0800 Subject: [PATCH 016/135] feat(agent): surface management diagnostics guidance --- crates/aionui-ai-agent/src/registry.rs | 98 ++++++++++++++++++- crates/aionui-ai-agent/src/registry_tests.rs | 62 ++++++++++++ .../src/services/availability/mod.rs | 13 ++- 3 files changed, 168 insertions(+), 5 deletions(-) diff --git a/crates/aionui-ai-agent/src/registry.rs b/crates/aionui-ai-agent/src/registry.rs index 81a98f9c4..53f878640 100644 --- a/crates/aionui-ai-agent/src/registry.rs +++ b/crates/aionui-ai-agent/src/registry.rs @@ -279,6 +279,7 @@ impl AgentRegistry { .cloned() .map(|meta| { let status = derive_management_status(&meta); + let diagnostics = derive_management_diagnostics(&meta, status); AgentManagementRow { id: meta.id, icon: meta.icon, @@ -303,9 +304,9 @@ impl AgentRegistry { status, last_check_status: meta.last_check_status, last_check_kind: meta.last_check_kind, - last_check_error_code: meta.last_check_error_code, - last_check_error_message: meta.last_check_error_message, - last_check_guidance: meta.last_check_guidance, + last_check_error_code: diagnostics.error_code, + last_check_error_message: diagnostics.error_message, + last_check_guidance: diagnostics.guidance, last_check_latency_ms: meta.last_check_latency_ms, last_check_at: meta.last_check_at, last_success_at: meta.last_success_at, @@ -572,6 +573,97 @@ fn derive_management_status(meta: &AgentMetadata) -> AgentManagementStatus { } } +struct ManagementDiagnostics { + error_code: Option, + error_message: Option, + guidance: Option, +} + +fn derive_management_diagnostics(meta: &AgentMetadata, status: AgentManagementStatus) -> ManagementDiagnostics { + let derived_reason = if matches!(status, AgentManagementStatus::Missing) { + probe_resolved_command(meta).err() + } else { + None + }; + + let error_code = meta + .last_check_error_code + .clone() + .or_else(|| derived_reason.as_ref().map(unavailable_reason_code)); + let error_message = meta + .last_check_error_message + .clone() + .or_else(|| derived_reason.as_ref().map(|reason| reason.to_string())); + let guidance = meta.last_check_guidance.clone().or_else(|| { + if let Some(reason) = derived_reason.as_ref() { + Some(guidance_for_unavailable_reason(reason)) + } else { + error_code + .as_deref() + .map(guidance_for_snapshot_error_code) + .filter(|guidance| !guidance.is_empty()) + .map(str::to_owned) + } + }); + + ManagementDiagnostics { + error_code, + error_message, + guidance, + } +} + +fn unavailable_reason_code(reason: &UnavailableReason) -> String { + match reason { + UnavailableReason::Disabled => "disabled", + UnavailableReason::NoCommand => "no_command", + UnavailableReason::BridgeMissing { .. } => "bridge_missing", + UnavailableReason::PrimaryMissing { .. } => "primary_missing", + UnavailableReason::CommandMissing { .. } => "command_missing", + UnavailableReason::ManagedRuntimeUnavailable { .. } => "managed_runtime_unavailable", + } + .to_owned() +} + +fn guidance_for_unavailable_reason(reason: &UnavailableReason) -> String { + match reason { + UnavailableReason::Disabled => "Enable this agent to make it available again.".to_owned(), + UnavailableReason::NoCommand => { + "Configure a spawn command for this agent, then run Test Connection again.".to_owned() + } + UnavailableReason::BridgeMissing { bridge } => { + format!("Install `{bridge}` and make sure it is available on PATH, then run Test Connection again.") + } + UnavailableReason::PrimaryMissing { binary } => { + format!("Install `{binary}` and make sure it is available on PATH, then run Test Connection again.") + } + UnavailableReason::CommandMissing { command } => { + format!("Install `{command}` and make sure it is available on PATH, then run Test Connection again.") + } + UnavailableReason::ManagedRuntimeUnavailable { resource, .. } => { + format!("Repair or reinstall the managed `{resource}` runtime, then run Test Connection again.") + } + } +} + +pub(crate) fn guidance_for_snapshot_error_code(error_code: &str) -> &'static str { + match error_code { + "command_not_found" => { + "Install the required CLI and make sure it is available on PATH, then run Test Connection again." + } + "acp_init_failed" => { + "The CLI was found, but ACP initialization failed. Complete sign-in or setup in the CLI, then run Test Connection again." + } + "health_check_failed" => { + "Open the CLI once to finish any first-run setup or sign-in flow, then run Test Connection again." + } + "session_send_failed" => { + "Fix the provider credentials or network issue that caused the last session failure, then start a new conversation." + } + _ => "", + } +} + fn decode_json_field(raw: Option<&str>, field: &str) -> Option { raw.and_then(|s| match serde_json::from_str(s) { Ok(v) => Some(v), diff --git a/crates/aionui-ai-agent/src/registry_tests.rs b/crates/aionui-ai-agent/src/registry_tests.rs index c383a83de..d591e8873 100644 --- a/crates/aionui-ai-agent/src/registry_tests.rs +++ b/crates/aionui-ai-agent/src/registry_tests.rs @@ -1,4 +1,8 @@ use super::*; +use aionui_db::{ + IAgentMetadataRepository, SqliteAgentMetadataRepository, UpsertAgentMetadataParams, init_database_memory, +}; +use std::sync::Arc; #[test] fn probe_resolved_command_accepts_bare_npx_when_managed_runtime_is_supported() { @@ -147,3 +151,61 @@ fn probe_resolved_command_requires_primary_binary_for_builtin_managed_codex() { UnavailableReason::PrimaryMissing { binary } if binary == "definitely-missing-codex-cli" )); } + +#[tokio::test] +async fn management_rows_derive_missing_diagnostics_from_probe_reason() { + let db = init_database_memory().await.unwrap(); + let repo: Arc = Arc::new(SqliteAgentMetadataRepository::new(db.pool().clone())); + + repo.upsert(&UpsertAgentMetadataParams { + id: "agent-missing-cli", + icon: None, + name: "Missing CLI Agent", + name_i18n: None, + description: None, + description_i18n: None, + backend: Some("custom".into()), + agent_type: "acp", + agent_source: "custom", + agent_source_info: Some(r#"{"binary_name":"definitely-missing-cli"}"#), + enabled: true, + command: Some("definitely-missing-cli"), + args: Some("[]"), + env: Some("[]"), + native_skills_dirs: None, + behavior_policy: None, + yolo_id: None, + agent_capabilities: None, + auth_methods: None, + config_options: None, + available_modes: None, + available_models: None, + available_commands: None, + sort_order: 100, + }) + .await + .unwrap(); + + let registry = AgentRegistry::new(repo); + registry.hydrate().await.unwrap(); + + let row = registry + .list_management_rows() + .await + .into_iter() + .find(|item| item.id == "agent-missing-cli") + .unwrap(); + + assert_eq!(row.status, AgentManagementStatus::Missing); + assert_eq!(row.last_check_error_code.as_deref(), Some("command_missing")); + assert!( + row.last_check_error_message + .as_deref() + .is_some_and(|message| message.contains("definitely-missing-cli")) + ); + assert!( + row.last_check_guidance + .as_deref() + .is_some_and(|guidance| guidance.contains("PATH")) + ); +} diff --git a/crates/aionui-ai-agent/src/services/availability/mod.rs b/crates/aionui-ai-agent/src/services/availability/mod.rs index c3d70d82a..6ea06fba7 100644 --- a/crates/aionui-ai-agent/src/services/availability/mod.rs +++ b/crates/aionui-ai-agent/src/services/availability/mod.rs @@ -20,7 +20,7 @@ use tokio::time::{Duration, sleep}; use crate::error::AgentError; use crate::protocol::{cli_detect, custom_agent_probe}; -use crate::registry::AgentRegistry; +use crate::registry::{AgentRegistry, guidance_for_snapshot_error_code}; const DEFAULT_STARTUP_DELAY: Duration = Duration::from_secs(15); const DEFAULT_SCHEDULED_INTERVAL: Duration = Duration::from_secs(300); @@ -155,7 +155,10 @@ impl AgentAvailabilityService { last_check_kind: Some(snapshot.kind), last_check_error_code: snapshot.error_code.as_deref(), last_check_error_message: snapshot.error_message.as_deref(), - last_check_guidance: None, + last_check_guidance: snapshot.error_code.as_deref().and_then(|code| { + let guidance = guidance_for_snapshot_error_code(code); + (!guidance.is_empty()).then_some(guidance) + }), last_check_latency_ms: Some(snapshot.latency_ms), last_check_at: Some(snapshot.checked_at), last_success_at: if snapshot.status == "available" { @@ -416,6 +419,12 @@ mod tests { row.last_check_error_message.as_deref(), Some("provider returned 401 invalid api key") ); + assert_eq!( + row.last_check_guidance.as_deref(), + Some( + "Fix the provider credentials or network issue that caused the last session failure, then start a new conversation." + ) + ); assert!(row.last_failure_at.is_some()); } From 742ba2b846a9b31d8e2c3a7641fc3f3f40baae25 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 03:40:51 +0800 Subject: [PATCH 017/135] fix(assistant): forbid editing generated assistants --- crates/aionui-assistant/src/service.rs | 121 ++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 5 deletions(-) diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 02bdd499d..2ef4c0c65 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -826,7 +826,12 @@ impl AssistantService { .map_err(|e| AssistantError::Internal(format!("rebuild legacy mirror: {e}")))?; return self.get(id).await; } - AssistantSource::Bare | AssistantSource::User => {} + AssistantSource::Bare => { + return Err(AssistantError::Forbidden( + "Generated assistants cannot be edited".into(), + )); + } + AssistantSource::User => {} } let serialized = SerializedFields::from_update(&req)?; @@ -1288,7 +1293,10 @@ impl AssistantService { AssistantSource::Builtin => Err(AssistantError::BadRequest( "Cannot write rule for built-in assistant".into(), )), - AssistantSource::Bare | AssistantSource::User => { + AssistantSource::Bare => Err(AssistantError::Forbidden( + "Cannot write rule for generated assistant".into(), + )), + AssistantSource::User => { let path = self.user_rule_path(id, locale); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) @@ -1306,7 +1314,10 @@ impl AssistantService { AssistantSource::Builtin => Err(AssistantError::BadRequest( "Cannot delete rule for built-in assistant".into(), )), - AssistantSource::Bare | AssistantSource::User => Ok(remove_assistant_md_files(&self.user_rules_dir(), id)), + AssistantSource::Bare => Err(AssistantError::Forbidden( + "Cannot delete rule for generated assistant".into(), + )), + AssistantSource::User => Ok(remove_assistant_md_files(&self.user_rules_dir(), id)), } } @@ -1325,7 +1336,10 @@ impl AssistantService { AssistantSource::Builtin => Err(AssistantError::BadRequest( "Cannot write skill for built-in assistant".into(), )), - AssistantSource::Bare | AssistantSource::User => { + AssistantSource::Bare => Err(AssistantError::Forbidden( + "Cannot write skill for generated assistant".into(), + )), + AssistantSource::User => { let path = self.user_skill_path(id, locale); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) @@ -1342,7 +1356,10 @@ impl AssistantService { AssistantSource::Builtin => Err(AssistantError::BadRequest( "Cannot delete skill for built-in assistant".into(), )), - AssistantSource::Bare | AssistantSource::User => Ok(remove_assistant_md_files(&self.user_skills_dir(), id)), + AssistantSource::Bare => Err(AssistantError::Forbidden( + "Cannot delete skill for generated assistant".into(), + )), + AssistantSource::User => Ok(remove_assistant_md_files(&self.user_skills_dir(), id)), } } @@ -2827,6 +2844,32 @@ mod tests { assert_eq!(detail.defaults.permission.value.as_deref(), Some("default")); } + #[tokio::test] + async fn update_bare_rejects() { + let fx = fixture_with_options(FixtureOpts { + agent_rows: vec![mk_agent_row( + "agent-claude", + "claude", + aionui_api_types::AgentManagementStatus::Available, + )], + ..Default::default() + }) + .await; + + let err = fx + .service + .update( + "bare:agent-claude", + UpdateAssistantRequest { + name: Some("Nope".into()), + ..Default::default() + }, + ) + .await + .unwrap_err(); + assert!(matches!(err, AssistantError::Forbidden(_))); + } + #[tokio::test] async fn update_builtin_changing_agent_without_defaults_clears_model_and_permission() { let fx = fixture_with_builtins(vec![mk_builtin("builtin-office", "Office")]).await; @@ -3396,6 +3439,74 @@ mod tests { assert!(matches!(err, AssistantError::BadRequest(_))); } + #[tokio::test] + async fn write_rule_bare_rejects() { + let fx = fixture_with_options(FixtureOpts { + agent_rows: vec![mk_agent_row( + "agent-claude", + "claude", + aionui_api_types::AgentManagementStatus::Available, + )], + ..Default::default() + }) + .await; + let err = fx + .service + .write_rule("bare:agent-claude", Some("en-US"), "x") + .await + .unwrap_err(); + assert!(matches!(err, AssistantError::Forbidden(_))); + } + + #[tokio::test] + async fn delete_rule_bare_rejects() { + let fx = fixture_with_options(FixtureOpts { + agent_rows: vec![mk_agent_row( + "agent-claude", + "claude", + aionui_api_types::AgentManagementStatus::Available, + )], + ..Default::default() + }) + .await; + let err = fx.service.delete_rule("bare:agent-claude").await.unwrap_err(); + assert!(matches!(err, AssistantError::Forbidden(_))); + } + + #[tokio::test] + async fn write_skill_bare_rejects() { + let fx = fixture_with_options(FixtureOpts { + agent_rows: vec![mk_agent_row( + "agent-claude", + "claude", + aionui_api_types::AgentManagementStatus::Available, + )], + ..Default::default() + }) + .await; + let err = fx + .service + .write_skill("bare:agent-claude", Some("en-US"), "x") + .await + .unwrap_err(); + assert!(matches!(err, AssistantError::Forbidden(_))); + } + + #[tokio::test] + async fn delete_skill_bare_rejects() { + let fx = fixture_with_options(FixtureOpts { + agent_rows: vec![mk_agent_row( + "agent-claude", + "claude", + aionui_api_types::AgentManagementStatus::Available, + )], + ..Default::default() + }) + .await; + let err = fx.service.delete_skill("bare:agent-claude").await.unwrap_err(); + assert!(matches!(err, AssistantError::Forbidden(_))); + } + #[tokio::test] async fn read_rule_builtin_dispatches_to_manifest() { let tmp = TempDir::new().unwrap(); From f2e7975436ec8142e92b80b82e405b8a368df27d Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 03:51:32 +0800 Subject: [PATCH 018/135] refactor(team): persist assistant identity for new agents --- crates/aionui-team/src/events.rs | 2 +- crates/aionui-team/src/prompts/lead.rs | 2 +- crates/aionui-team/src/prompts/mod.rs | 4 +-- crates/aionui-team/src/prompts/teammate.rs | 2 +- crates/aionui-team/src/provisioning.rs | 11 +++---- crates/aionui-team/src/scheduler/tests.rs | 2 +- .../src/service/response_builder.rs | 4 +-- crates/aionui-team/src/session.rs | 6 ++-- crates/aionui-team/src/types.rs | 32 ++++++++++++------- crates/aionui-team/tests/e2e_smoke.rs | 4 +-- crates/aionui-team/tests/e2e_team_flow.rs | 10 +++--- .../tests/mcp_server_integration.rs | 4 +-- .../tests/prompts_events_integration.rs | 2 +- .../tests/scheduler_integration.rs | 2 +- .../tests/session_service_integration.rs | 3 +- 15 files changed, 49 insertions(+), 41 deletions(-) diff --git a/crates/aionui-team/src/events.rs b/crates/aionui-team/src/events.rs index 30dc2cf49..57794538d 100644 --- a/crates/aionui-team/src/events.rs +++ b/crates/aionui-team/src/events.rs @@ -197,7 +197,7 @@ mod tests { conversation_id: "conv-2".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: Some(TeammateStatus::Idle), conversation_type: None, cli_path: None, diff --git a/crates/aionui-team/src/prompts/lead.rs b/crates/aionui-team/src/prompts/lead.rs index 50f759f58..39d422397 100644 --- a/crates/aionui-team/src/prompts/lead.rs +++ b/crates/aionui-team/src/prompts/lead.rs @@ -204,7 +204,7 @@ mod tests { conversation_id: format!("conv-{slot_id}"), backend: backend.into(), model: "sonnet".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, diff --git a/crates/aionui-team/src/prompts/mod.rs b/crates/aionui-team/src/prompts/mod.rs index 824473a33..1dd6d5549 100644 --- a/crates/aionui-team/src/prompts/mod.rs +++ b/crates/aionui-team/src/prompts/mod.rs @@ -145,7 +145,7 @@ mod tests { conversation_id: "conv-1".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, @@ -160,7 +160,7 @@ mod tests { conversation_id: format!("conv-{slot_id}"), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, diff --git a/crates/aionui-team/src/prompts/teammate.rs b/crates/aionui-team/src/prompts/teammate.rs index 7fcdb319c..401ab3a1f 100644 --- a/crates/aionui-team/src/prompts/teammate.rs +++ b/crates/aionui-team/src/prompts/teammate.rs @@ -268,7 +268,7 @@ mod tests { conversation_id: format!("conv-{slot_id}"), backend: backend.into(), model: "default".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, diff --git a/crates/aionui-team/src/provisioning.rs b/crates/aionui-team/src/provisioning.rs index 40b40803f..320ebe20e 100644 --- a/crates/aionui-team/src/provisioning.rs +++ b/crates/aionui-team/src/provisioning.rs @@ -172,7 +172,7 @@ impl TeamAgentProvisioner { conversation_id: leader_conversation.conversation_id, backend: leader_input.backend.clone(), model: leader_input.model.clone(), - custom_agent_id: Self::effective_assistant_id( + assistant_id: Self::effective_assistant_id( leader_input.assistant_id.as_deref(), leader_input.custom_agent_id.as_deref(), ), @@ -206,7 +206,7 @@ impl TeamAgentProvisioner { conversation_id: conversation.conversation_id, backend: input.backend.clone(), model: input.model.clone(), - custom_agent_id: Self::effective_assistant_id( + assistant_id: Self::effective_assistant_id( input.assistant_id.as_deref(), input.custom_agent_id.as_deref(), ), @@ -378,7 +378,7 @@ impl TeamAgentProvisioner { conversation_id: conversation.conversation_id, backend: input.backend, model: input.model, - custom_agent_id: input.assistant_id, + assistant_id: input.assistant_id, status: None, conversation_type: None, cli_path: None, @@ -395,12 +395,12 @@ impl TeamAgentProvisioner { name: &str, backend: &str, model: &str, - custom_agent_id: Option<&str>, + assistant_id: Option<&str>, existing_conversation_id: Option<&str>, workspace: Option<&str>, ) -> Result { let extra = self - .build_team_extra(team_id, slot_id, role, backend, model, custom_agent_id, workspace) + .build_team_extra(team_id, slot_id, role, backend, model, assistant_id, workspace) .await?; if let Some(existing_id) = existing_conversation_id { self.conversation_port @@ -546,7 +546,6 @@ impl TeamAgentProvisioner { } if let Some(assistant_id) = assistant_id { extra["assistant_id"] = serde_json::Value::String(assistant_id.to_owned()); - extra["custom_agent_id"] = serde_json::Value::String(assistant_id.to_owned()); extra["preset_assistant_id"] = serde_json::Value::String(assistant_id.to_owned()); } if let Some(workspace) = workspace { diff --git a/crates/aionui-team/src/scheduler/tests.rs b/crates/aionui-team/src/scheduler/tests.rs index 12c3a0bd5..14e22c2f7 100644 --- a/crates/aionui-team/src/scheduler/tests.rs +++ b/crates/aionui-team/src/scheduler/tests.rs @@ -74,7 +74,7 @@ fn make_agent(slot_id: &str, name: &str, role: TeammateRole) -> TeamAgent { conversation_id: format!("conv-{slot_id}"), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, diff --git a/crates/aionui-team/src/service/response_builder.rs b/crates/aionui-team/src/service/response_builder.rs index 023f6072c..42cf31a1f 100644 --- a/crates/aionui-team/src/service/response_builder.rs +++ b/crates/aionui-team/src/service/response_builder.rs @@ -36,8 +36,8 @@ impl TeamSessionService { } async fn resolve_agent_icon(&self, agent: &TeamAgent) -> Result, TeamError> { - if let Some(custom_agent_id) = agent.custom_agent_id.as_deref() - && let Some(row) = self.agent_metadata_repo.get(custom_agent_id).await? + if let Some(assistant_id) = agent.assistant_id.as_deref() + && let Some(row) = self.agent_metadata_repo.get(assistant_id).await? && row.icon.is_some() { return Ok(row.icon); diff --git a/crates/aionui-team/src/session.rs b/crates/aionui-team/src/session.rs index fa219bce3..4acaf36d0 100644 --- a/crates/aionui-team/src/session.rs +++ b/crates/aionui-team/src/session.rs @@ -1422,7 +1422,7 @@ mod tests { conversation_id: "c1".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, @@ -1434,7 +1434,7 @@ mod tests { conversation_id: "c2".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, @@ -2176,7 +2176,7 @@ mod tests { conversation_id: "c3".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, diff --git a/crates/aionui-team/src/types.rs b/crates/aionui-team/src/types.rs index d1e663324..af1838284 100644 --- a/crates/aionui-team/src/types.rs +++ b/crates/aionui-team/src/types.rs @@ -98,8 +98,15 @@ pub struct TeamAgent { pub backend: String, #[serde(default)] pub model: String, - #[serde(skip_serializing_if = "Option::is_none", alias = "customAgentId")] - pub custom_agent_id: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "assistant_id", + alias = "assistantId", + alias = "custom_agent_id", + alias = "customAgentId" + )] + pub assistant_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, #[serde(default, skip_serializing_if = "Option::is_none", alias = "conversationType")] @@ -122,8 +129,8 @@ impl TeamAgent { backend: self.backend.clone(), icon, model: self.model.clone(), - assistant_id: self.custom_agent_id.clone(), - custom_agent_id: self.custom_agent_id.clone(), + assistant_id: self.assistant_id.clone(), + custom_agent_id: self.assistant_id.clone(), status: self.status.map(|s| s.to_string()), pending_confirmations: 0, } @@ -512,7 +519,7 @@ mod tests { conversation_id: "c1".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: Some("custom-1".into()), + assistant_id: Some("custom-1".into()), status: Some(TeammateStatus::Working), conversation_type: None, cli_path: None, @@ -522,6 +529,7 @@ mod tests { assert_eq!(resp.role, "lead"); assert!(resp.icon.is_none()); assert_eq!(resp.status.as_deref(), Some("working")); + assert_eq!(resp.assistant_id.as_deref(), Some("custom-1")); assert_eq!(resp.custom_agent_id.as_deref(), Some("custom-1")); } @@ -534,7 +542,7 @@ mod tests { conversation_id: "c1".into(), backend: "claude".into(), model: "opus".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, @@ -554,7 +562,7 @@ mod tests { conversation_id: "c1".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, @@ -573,7 +581,7 @@ mod tests { conversation_id: "c1".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: Some("x".into()), + assistant_id: Some("x".into()), status: Some(TeammateStatus::Idle), conversation_type: None, cli_path: None, @@ -581,7 +589,8 @@ mod tests { let val = serde_json::to_value(&agent).unwrap(); assert!(val.get("slot_id").is_some()); assert!(val.get("conversation_id").is_some()); - assert!(val.get("custom_agent_id").is_some()); + assert!(val.get("assistant_id").is_some()); + assert!(val.get("custom_agent_id").is_none()); } #[test] @@ -602,6 +611,7 @@ mod tests { assert_eq!(agent.role, TeammateRole::Lead); assert_eq!(agent.status, Some(TeammateStatus::Working)); assert_eq!(agent.conversation_type.as_deref(), Some("acp")); + assert_eq!(agent.assistant_id.as_deref(), Some("custom-1")); } // -- Team from_row -------------------------------------------------------- @@ -615,7 +625,7 @@ mod tests { conversation_id: "c1".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, @@ -654,7 +664,7 @@ mod tests { conversation_id: "c1".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: Some(TeammateStatus::Idle), conversation_type: None, cli_path: None, diff --git a/crates/aionui-team/tests/e2e_smoke.rs b/crates/aionui-team/tests/e2e_smoke.rs index 8d3951622..60ec99c85 100644 --- a/crates/aionui-team/tests/e2e_smoke.rs +++ b/crates/aionui-team/tests/e2e_smoke.rs @@ -81,7 +81,7 @@ async fn setup_team_with_lead() -> SmokeEnv { conversation_id: "conv-lead".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, @@ -93,7 +93,7 @@ async fn setup_team_with_lead() -> SmokeEnv { conversation_id: "conv-worker".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, diff --git a/crates/aionui-team/tests/e2e_team_flow.rs b/crates/aionui-team/tests/e2e_team_flow.rs index cc99c3df9..0f6ec8e59 100644 --- a/crates/aionui-team/tests/e2e_team_flow.rs +++ b/crates/aionui-team/tests/e2e_team_flow.rs @@ -527,7 +527,7 @@ fn two_agents() -> Vec { conversation_id: "conv-lead".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, @@ -539,7 +539,7 @@ fn two_agents() -> Vec { conversation_id: "conv-worker".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, @@ -1047,7 +1047,7 @@ async fn s4_dynamic_agent_added_then_finish_propagates() { conversation_id: "conv-helper".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, @@ -1153,7 +1153,7 @@ async fn s4b_pending_wake_for_unregistered_dynamic_agent_survives_leader_empty_w conversation_id: "conv-helper".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, @@ -1401,7 +1401,7 @@ async fn s7_team_members_reflects_dynamic_roster() { conversation_id: "conv-extra".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, diff --git a/crates/aionui-team/tests/mcp_server_integration.rs b/crates/aionui-team/tests/mcp_server_integration.rs index 6efdf794c..a32bfca89 100644 --- a/crates/aionui-team/tests/mcp_server_integration.rs +++ b/crates/aionui-team/tests/mcp_server_integration.rs @@ -49,7 +49,7 @@ fn make_agents() -> Vec { conversation_id: "conv-lead".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, @@ -61,7 +61,7 @@ fn make_agents() -> Vec { conversation_id: "conv-worker".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, diff --git a/crates/aionui-team/tests/prompts_events_integration.rs b/crates/aionui-team/tests/prompts_events_integration.rs index b51d9a2df..e488ab583 100644 --- a/crates/aionui-team/tests/prompts_events_integration.rs +++ b/crates/aionui-team/tests/prompts_events_integration.rs @@ -97,7 +97,7 @@ fn make_agent(slot_id: &str, name: &str, role: TeammateRole) -> TeamAgent { conversation_id: format!("conv-{slot_id}"), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, diff --git a/crates/aionui-team/tests/scheduler_integration.rs b/crates/aionui-team/tests/scheduler_integration.rs index 808e5df4c..05d44efcf 100644 --- a/crates/aionui-team/tests/scheduler_integration.rs +++ b/crates/aionui-team/tests/scheduler_integration.rs @@ -50,7 +50,7 @@ fn make_agent(slot_id: &str, name: &str, role: TeammateRole) -> TeamAgent { conversation_id: format!("conv-{slot_id}"), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, conversation_type: None, cli_path: None, diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index c20b3d7cf..ba3a936e0 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -1398,7 +1398,7 @@ async fn create_team_without_workspace_uses_leader_auto_workspace_for_all_initia } #[tokio::test] -async fn tc_create_team_uses_custom_agent_id_icon_lookup() { +async fn tc_create_team_uses_assistant_id_icon_lookup() { let svc = setup_with_metadata_rows(vec![make_agent_metadata_row( "2d23ff1c", "claude", @@ -1470,7 +1470,6 @@ async fn tc_create_team_carries_assistant_identity_into_lead_conversation_extra( let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap(); assert_eq!(extra["assistant_id"], serde_json::json!("2d23ff1c")); - assert_eq!(extra["custom_agent_id"], serde_json::json!("2d23ff1c")); assert_eq!(extra["preset_assistant_id"], serde_json::json!("2d23ff1c")); } From 86033c2edf75e73d32e426c00a2e3428ed4b5df3 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 04:19:54 +0800 Subject: [PATCH 019/135] refactor(conversation): inject assistant runtime seeds --- crates/aionui-app/tests/conversation_e2e.rs | 2 ++ crates/aionui-conversation/src/service.rs | 11 +++++++++++ crates/aionui-conversation/src/service_test.rs | 4 ++++ .../src/service_test/acp_error_recovery_test.rs | 1 + 4 files changed, 18 insertions(+) diff --git a/crates/aionui-app/tests/conversation_e2e.rs b/crates/aionui-app/tests/conversation_e2e.rs index 36dd40a1a..f1abeeef3 100644 --- a/crates/aionui-app/tests/conversation_e2e.rs +++ b/crates/aionui-app/tests/conversation_e2e.rs @@ -239,6 +239,8 @@ async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { assert_eq!(data["extra"]["assistant_id"], "u1"); assert_eq!(data["extra"]["preset_assistant_id"], "u1"); assert_eq!(data["extra"]["preset_context"], "assistant snapshot rule"); + assert_eq!(data["extra"]["session_mode"], "workspace-write"); + assert_eq!(data["extra"]["current_mode_id"], "workspace-write"); assert_eq!(data["extra"]["current_model_id"], "override-model"); assert!(data["extra"].get("assistant_snapshot").is_none()); assert!( diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 425df990e..665865834 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -695,6 +695,17 @@ impl ConversationService { serde_json::Value::String(model_id.clone()), ); } + if let Some(permission) = snapshot.resolved_defaults.permission.as_ref() { + if !obj.contains_key("session_mode") { + obj.insert("session_mode".to_owned(), serde_json::Value::String(permission.clone())); + } + if matches!(req.r#type, AgentType::Acp) && !obj.contains_key("current_mode_id") { + obj.insert( + "current_mode_id".to_owned(), + serde_json::Value::String(permission.clone()), + ); + } + } } // Consume transient skill-shaping inputs and freeze the initial diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 53b082cc2..51e654c27 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -618,6 +618,7 @@ impl IAgentMetadataRepository for StubAgentMetadataRepo { #[derive(Debug, Clone, PartialEq, Eq)] struct RuntimeStateSaveCall { conversation_id: String, + current_mode_id: Option>, current_model_id: Option>, } @@ -671,6 +672,7 @@ impl IAcpSessionRepository for StubAcpSessionRepo { ) -> Result { self.runtime_state_saves.lock().unwrap().push(RuntimeStateSaveCall { conversation_id: conversation_id.to_owned(), + current_mode_id: params.current_mode_id.map(|outer| outer.map(ToOwned::to_owned)), current_model_id: params.current_model_id.map(|outer| outer.map(ToOwned::to_owned)), }); Ok(true) @@ -4083,6 +4085,8 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { assert_eq!(resp.extra["assistant_id"], json!("preset-1")); assert_eq!(resp.extra["preset_assistant_id"], json!("preset-1")); assert_eq!(resp.extra["preset_context"], json!("assistant rule body")); + assert_eq!(resp.extra["session_mode"], json!("workspace-write")); + assert_eq!(resp.extra["current_mode_id"], json!("workspace-write")); assert_eq!(resp.extra["current_model_id"], json!("new-model")); assert_eq!(resp.extra["skills"], json!(["cron", "pdf"])); assert!(resp.extra.get("assistant_snapshot").is_none()); diff --git a/crates/aionui-conversation/src/service_test/acp_error_recovery_test.rs b/crates/aionui-conversation/src/service_test/acp_error_recovery_test.rs index b435e6a52..c818bd7b8 100644 --- a/crates/aionui-conversation/src/service_test/acp_error_recovery_test.rs +++ b/crates/aionui-conversation/src/service_test/acp_error_recovery_test.rs @@ -100,6 +100,7 @@ async fn send_message_clears_persisted_acp_model_after_model_not_found() { acp_session_repo.runtime_state_saves(), vec![RuntimeStateSaveCall { conversation_id: conv.id.clone(), + current_mode_id: None, current_model_id: Some(None), }] ); From 907ace6962c9087bf299dd5465145a5fdb7d809c Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 04:30:53 +0800 Subject: [PATCH 020/135] refactor(cron): strip legacy agent ids on assistant writes --- crates/aionui-cron/src/service.rs | 130 ++++++++++++++++-- .../aionui-cron/tests/service_integration.rs | 68 +++++++++ 2 files changed, 184 insertions(+), 14 deletions(-) diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index 75aa3d98b..c967e7f78 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -77,19 +77,22 @@ impl CronService { let created_by = CreatedBy::from_str(&req.created_by)?; let message = req.message.or(req.prompt).unwrap_or_default(); - let agent_config = req.agent_config.map(|c| CronAgentConfig { - backend: c.backend, - name: c.name, - cli_path: c.cli_path, - is_preset: c.is_preset, - assistant_id: c.assistant_id, - custom_agent_id: c.custom_agent_id, - preset_agent_type: c.preset_agent_type, - mode: c.mode, - model_id: c.model_id, - config_options: c.config_options, - workspace: c.workspace, - }); + let agent_config = req + .agent_config + .map(sanitize_agent_config_dto) + .map(|c| CronAgentConfig { + backend: c.backend, + name: c.name, + cli_path: c.cli_path, + is_preset: c.is_preset, + assistant_id: c.assistant_id, + custom_agent_id: c.custom_agent_id, + preset_agent_type: c.preset_agent_type, + mode: c.mode, + model_id: c.model_id, + config_options: c.config_options, + workspace: c.workspace, + }); let now = now_ms(); let next_run_at = compute_next_run(&schedule, now); @@ -161,7 +164,8 @@ impl CronService { job.execution_mode = parse_execution_mode(Some(mode_str))?; } if let Some(config_dto) = &req.agent_config { - validate_aionrs_agent_config(&job.agent_type, Some(config_dto))?; + let config_dto = sanitize_agent_config_dto(config_dto.clone()); + validate_aionrs_agent_config(&job.agent_type, Some(&config_dto))?; job.agent_config = Some(CronAgentConfig { backend: config_dto.backend.clone(), name: config_dto.name.clone(), @@ -1210,6 +1214,7 @@ fn build_update_params(job: &CronJob, req: &UpdateCronJobRequest) -> UpdateCronJ }; let agent_config = req.agent_config.as_ref().map(|c| { + let c = sanitize_agent_config_dto(c.clone()); let config = CronAgentConfig { backend: c.backend.clone(), name: c.name.clone(), @@ -1254,6 +1259,19 @@ fn build_update_params(job: &CronJob, req: &UpdateCronJobRequest) -> UpdateCronJ } } +fn sanitize_agent_config_dto(mut config: aionui_api_types::CronAgentConfigDto) -> aionui_api_types::CronAgentConfigDto { + let has_assistant_id = config + .assistant_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); + if has_assistant_id { + config.custom_agent_id = None; + config.preset_agent_type = None; + config.is_preset = None; + } + config +} + fn schedule_from_dto_with_existing_timezone(dto: &CronScheduleDto, existing: &CronSchedule) -> CronSchedule { match dto { CronScheduleDto::Cron { expr, tz, description } => CronSchedule::Cron { @@ -1382,6 +1400,53 @@ mod tests { assert!(validate_aionrs_agent_config("claude", Some(&cfg)).is_ok()); } + #[test] + fn sanitize_agent_config_dto_clears_legacy_ids_when_assistant_id_present() { + let config = aionui_api_types::CronAgentConfigDto { + backend: "claude".into(), + name: "Helper".into(), + cli_path: None, + is_preset: Some(true), + assistant_id: Some("assistant-1".into()), + custom_agent_id: Some("legacy-assistant".into()), + preset_agent_type: Some("claude".into()), + mode: Some("default".into()), + model_id: Some("claude-sonnet-4".into()), + config_options: None, + workspace: None, + }; + + let sanitized = sanitize_agent_config_dto(config); + + assert_eq!(sanitized.assistant_id.as_deref(), Some("assistant-1")); + assert!(sanitized.custom_agent_id.is_none()); + assert!(sanitized.preset_agent_type.is_none()); + assert!(sanitized.is_preset.is_none()); + } + + #[test] + fn sanitize_agent_config_dto_keeps_legacy_ids_without_assistant_id() { + let config = aionui_api_types::CronAgentConfigDto { + backend: "claude".into(), + name: "Helper".into(), + cli_path: None, + is_preset: Some(true), + assistant_id: None, + custom_agent_id: Some("legacy-assistant".into()), + preset_agent_type: Some("claude".into()), + mode: Some("default".into()), + model_id: Some("claude-sonnet-4".into()), + config_options: None, + workspace: None, + }; + + let sanitized = sanitize_agent_config_dto(config); + + assert_eq!(sanitized.custom_agent_id.as_deref(), Some("legacy-assistant")); + assert_eq!(sanitized.preset_agent_type.as_deref(), Some("claude")); + assert_eq!(sanitized.is_preset, Some(true)); + } + // -- parse_execution_mode ------------------------------------------------- #[test] @@ -1493,6 +1558,43 @@ mod tests { assert!(params.next_run_at.is_some()); } + #[test] + fn build_update_params_strips_legacy_ids_when_assistant_id_present() { + let job = sample_job(); + let req = UpdateCronJobRequest { + name: None, + description: None, + enabled: None, + schedule: None, + message: None, + execution_mode: None, + agent_config: Some(aionui_api_types::CronAgentConfigDto { + backend: "claude".into(), + name: "Helper".into(), + cli_path: None, + is_preset: Some(true), + assistant_id: Some("assistant-1".into()), + custom_agent_id: Some("legacy-assistant".into()), + preset_agent_type: Some("claude".into()), + mode: Some("default".into()), + model_id: Some("claude-sonnet-4".into()), + config_options: None, + workspace: None, + }), + conversation_title: None, + max_retries: None, + }; + + let params = build_update_params(&job, &req); + let config_json = params.agent_config.flatten().expect("agent config json"); + let config: CronAgentConfig = serde_json::from_str(&config_json).expect("parse cron config"); + + assert_eq!(config.assistant_id.as_deref(), Some("assistant-1")); + assert!(config.custom_agent_id.is_none()); + assert!(config.preset_agent_type.is_none()); + assert!(config.is_preset.is_none()); + } + #[test] fn preserves_existing_cron_timezone_when_update_omits_tz() { let existing = CronSchedule::Cron { diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index d19756813..c36a47fad 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -676,6 +676,33 @@ async fn cj1_create_cron_job() { assert_eq!(events[0].name, "cron.job-created"); } +#[tokio::test] +async fn create_job_strips_legacy_agent_ids_when_assistant_id_present() { + let (svc, _, _) = setup().await; + let mut req = make_create_req("Assistant Only Create", every_60s()); + req.agent_config = Some(aionui_api_types::CronAgentConfigDto { + backend: "claude".into(), + name: "Helper".into(), + cli_path: None, + is_preset: Some(true), + assistant_id: Some("assistant-1".into()), + custom_agent_id: Some("legacy-assistant".into()), + preset_agent_type: Some("claude".into()), + mode: Some("default".into()), + model_id: Some("claude-sonnet-4".into()), + config_options: None, + workspace: None, + }); + + let job = svc.add_job(req).await.unwrap(); + let config = job.agent_config.expect("agent config"); + + assert_eq!(config.assistant_id.as_deref(), Some("assistant-1")); + assert!(config.custom_agent_id.is_none()); + assert!(config.preset_agent_type.is_none()); + assert!(config.is_preset.is_none()); +} + #[tokio::test] async fn create_job_rejects_deprecated_agent_types() { let (svc, _, _) = setup().await; @@ -829,6 +856,47 @@ async fn cj8_update_job() { assert_eq!(events[0].name, "cron.job-updated"); } +#[tokio::test] +async fn update_job_strips_legacy_agent_ids_when_assistant_id_present() { + let (svc, _, _) = setup().await; + let created = svc + .add_job(make_create_req("Assistant Only Update", every_60s())) + .await + .unwrap(); + + let req = UpdateCronJobRequest { + name: None, + description: None, + enabled: None, + schedule: None, + message: None, + execution_mode: None, + agent_config: Some(aionui_api_types::CronAgentConfigDto { + backend: "claude".into(), + name: "Helper".into(), + cli_path: None, + is_preset: Some(true), + assistant_id: Some("assistant-1".into()), + custom_agent_id: Some("legacy-assistant".into()), + preset_agent_type: Some("claude".into()), + mode: Some("default".into()), + model_id: Some("claude-sonnet-4".into()), + config_options: None, + workspace: None, + }), + conversation_title: None, + max_retries: None, + }; + + let updated = svc.update_job(&created.id, req).await.unwrap(); + let config = updated.agent_config.expect("agent config"); + + assert_eq!(config.assistant_id.as_deref(), Some("assistant-1")); + assert!(config.custom_agent_id.is_none()); + assert!(config.preset_agent_type.is_none()); + assert!(config.is_preset.is_none()); +} + // ── CJ-9: Update schedule type ──────────────────────────────────── #[tokio::test] From d6c81616a33c74bce68c6c3735f2a304232d402b Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 04:39:06 +0800 Subject: [PATCH 021/135] refactor(team): prefer assistant ids in mcp tooling --- crates/aionui-team/src/mcp/server.rs | 7 +-- crates/aionui-team/src/mcp/tools.rs | 45 +++++++++++++++---- crates/aionui-team/src/provisioning.rs | 4 +- .../aionui-team/src/service/spawn_support.rs | 4 +- crates/aionui-team/src/session.rs | 8 ++-- .../tests/session_service_integration.rs | 2 +- 6 files changed, 49 insertions(+), 21 deletions(-) diff --git a/crates/aionui-team/src/mcp/server.rs b/crates/aionui-team/src/mcp/server.rs index 035751963..8400f495a 100644 --- a/crates/aionui-team/src/mcp/server.rs +++ b/crates/aionui-team/src/mcp/server.rs @@ -461,11 +461,12 @@ async fn exec_list_models(args: &Value, service: &Weak) -> R async fn exec_describe_assistant(args: &Value, service: &Weak) -> Result { let assistant_key = args - .get("custom_agent_id") + .get("assistant_id") + .or_else(|| args.get("custom_agent_id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) - .ok_or_else(|| "Missing required field: custom_agent_id".to_owned())?; + .ok_or_else(|| "Missing required field: assistant_id".to_owned())?; let locale = args.get("locale").and_then(Value::as_str); let service = service .upgrade() @@ -617,7 +618,7 @@ async fn exec_spawn_agent( let req = SpawnAgentRequest { name: requested_name.clone(), agent_type, - custom_agent_id: input.custom_agent_id, + assistant_id: input.assistant_id, model: input.model, }; diff --git a/crates/aionui-team/src/mcp/tools.rs b/crates/aionui-team/src/mcp/tools.rs index 79798c58b..09986b564 100644 --- a/crates/aionui-team/src/mcp/tools.rs +++ b/crates/aionui-team/src/mcp/tools.rs @@ -50,7 +50,7 @@ judge whether it fits the user's request. Use this when two or more presets look relevant from the one-line catalog in your system prompt. Only works on preset assistants listed in \"Available Preset Assistants for Spawning\". -After confirming a match, call team_spawn_agent with the same custom_agent_id."; +After confirming a match, call team_spawn_agent with the same assistant_id."; // --------------------------------------------------------------------------- // Tool descriptors (returned by tools/list) @@ -86,7 +86,7 @@ pub fn all_tool_descriptors() -> Vec { "name": { "type": "string", "description": "Agent display name" }, "agent_type": { "type": "string", "description": "Agent type/backend to use (e.g. \"claude\", \"codex\", \"codebuddy\", \"gemini\"). Query team_list_models first to see available options." }, "model": { "type": "string", "description": "Specific model ID to use (e.g. \"claude-sonnet-4\"). Must be a valid model for the chosen agent_type. Query team_list_models to see available models." }, - "custom_agent_id": { "type": "string", "description": "Preset assistant ID to spawn (from the Available Preset Assistants catalog). When set, agent_type is derived from the preset's backend." }, + "assistant_id": { "type": "string", "description": "Preset assistant ID to spawn (from the Available Preset Assistants catalog). When set, agent_type is derived from the preset's backend." }, "backend": { "type": "string", "description": "Legacy alias for agent_type. Prefer agent_type." }, "role": { "type": "string", "description": "Agent role (default: 'teammate')" } }, @@ -169,10 +169,10 @@ pub fn all_tool_descriptors() -> Vec { input_schema: json!({ "type": "object", "properties": { - "custom_agent_id": { "type": "string", "description": "The preset assistant ID from the \"Available Preset Assistants\" catalog (e.g., \"word-creator\")." }, + "assistant_id": { "type": "string", "description": "The preset assistant ID from the \"Available Preset Assistants\" catalog (e.g., \"word-creator\")." }, "locale": { "type": "string", "description": "Locale like \"zh-CN\" or \"en-US\". Defaults to the user's current UI language when omitted." } }, - "required": ["custom_agent_id"] + "required": ["assistant_id"] }), }, ToolDescriptor { @@ -201,7 +201,7 @@ pub struct SendMessageInput { /// Arguments for the `team_spawn_agent` MCP tool call. /// /// The AionUi contract (`docs/teams/phase1/aionui-audit.md` §2.1) names the -/// agent-type field `agent_type` and adds `custom_agent_id` + `model`. The +/// agent-type field `agent_type` and adds `assistant_id` + `model`. The /// phase-1 Rust dispatch originally exposed `backend` (and `role`); those are /// preserved for back-compat and used as fallbacks when the modern fields /// are not provided — `backend` is treated as an alias for `agent_type`. @@ -215,7 +215,8 @@ pub struct SpawnAgentInput { #[serde(default)] pub agent_type: Option, #[serde(default)] - pub custom_agent_id: Option, + #[serde(alias = "assistantId", alias = "custom_agent_id", alias = "customAgentId")] + pub assistant_id: Option, #[serde(default)] pub model: Option, } @@ -514,8 +515,8 @@ mod tests { "schema must expose 'agent_type' field" ); assert!( - props.contains_key("custom_agent_id"), - "schema must expose 'custom_agent_id' field" + props.contains_key("assistant_id"), + "schema must expose 'assistant_id' field" ); } @@ -699,10 +700,36 @@ mod tests { ); assert!( desc.description - .contains("After confirming a match, call team_spawn_agent with the same custom_agent_id.") + .contains("After confirming a match, call team_spawn_agent with the same assistant_id.") ); } + #[test] + fn team_describe_assistant_schema_prefers_assistant_id() { + let desc = all_tool_descriptors() + .into_iter() + .find(|d| d.name == "team_describe_assistant") + .unwrap(); + let props = desc.input_schema["properties"].as_object().unwrap(); + let required = desc.input_schema["required"].as_array().unwrap(); + let names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); + assert!(props.contains_key("assistant_id")); + assert!(!props.contains_key("custom_agent_id")); + assert!(names.contains(&"assistant_id")); + assert!(!names.contains(&"custom_agent_id")); + } + + #[test] + fn parse_spawn_agent_accepts_legacy_custom_agent_id_alias() { + let input: SpawnAgentInput = serde_json::from_value(json!({ + "name": "Preset helper", + "backend": "claude", + "custom_agent_id": "word-creator", + })) + .unwrap(); + assert_eq!(input.assistant_id.as_deref(), Some("word-creator")); + } + // ---- D4 handlers return non-error payloads ---- #[test] diff --git a/crates/aionui-team/src/provisioning.rs b/crates/aionui-team/src/provisioning.rs index 320ebe20e..867803df8 100644 --- a/crates/aionui-team/src/provisioning.rs +++ b/crates/aionui-team/src/provisioning.rs @@ -267,7 +267,7 @@ impl TeamAgentProvisioner { name: String, backend: String, model: String, - custom_agent_id: Option, + assistant_id: Option, ) -> Result { let row = self .repo @@ -284,7 +284,7 @@ impl TeamAgentProvisioner { role: TeammateRole::Teammate, backend, model, - assistant_id: custom_agent_id, + assistant_id, workspace: Some(workspace), }) .await?; diff --git a/crates/aionui-team/src/service/spawn_support.rs b/crates/aionui-team/src/service/spawn_support.rs index 08c8f0f94..594a6a3c1 100644 --- a/crates/aionui-team/src/service/spawn_support.rs +++ b/crates/aionui-team/src/service/spawn_support.rs @@ -221,7 +221,7 @@ impl TeamSessionService { name: String, backend: String, model: String, - custom_agent_id: Option, + assistant_id: Option, ) -> Result { let lock = self .add_agent_locks @@ -231,7 +231,7 @@ impl TeamSessionService { let _guard = lock.lock().await; self.provisioner() - .persist_spawned_agent(user_id, team_id, name, backend, model, custom_agent_id) + .persist_spawned_agent(user_id, team_id, name, backend, model, assistant_id) .await } } diff --git a/crates/aionui-team/src/session.rs b/crates/aionui-team/src/session.rs index 4acaf36d0..1e8ec6060 100644 --- a/crates/aionui-team/src/session.rs +++ b/crates/aionui-team/src/session.rs @@ -54,7 +54,7 @@ pub struct WakeInput { pub struct SpawnAgentRequest { pub name: String, pub agent_type: Option, - pub custom_agent_id: Option, + pub assistant_id: Option, pub model: Option, } @@ -1032,7 +1032,7 @@ impl TeamSession { requested_name, backend, model, - req.custom_agent_id.clone(), + req.assistant_id.clone(), ) .await?; @@ -2297,7 +2297,7 @@ mod tests { SpawnAgentRequest { name: "Helper".into(), agent_type: None, - custom_agent_id: None, + assistant_id: None, model: None, } } @@ -2751,7 +2751,7 @@ mod tests { SpawnAgentRequest { name: "Helper".into(), agent_type: agent_type.map(str::to_owned), - custom_agent_id: None, + assistant_id: None, model: None, } } diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index ba3a936e0..0e9a3e3ef 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -2418,7 +2418,7 @@ async fn spawn_agent_in_session_rejects_without_active_team_run_before_persistin let req = SpawnAgentRequest { name: "Helper".into(), agent_type: Some("claude".into()), - custom_agent_id: None, + assistant_id: None, model: Some("claude-sonnet-4".into()), }; From afb071e5851f6cb3b9e9c0cf10b082f0a75165bd Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 05:18:05 +0800 Subject: [PATCH 022/135] fix(team): resolve spawn backend from assistant ids --- .../src/service/describe_support.rs | 2 +- .../aionui-team/src/service/spawn_support.rs | 250 ++++++++++++++++++ crates/aionui-team/src/session.rs | 45 ++-- 3 files changed, 272 insertions(+), 25 deletions(-) diff --git a/crates/aionui-team/src/service/describe_support.rs b/crates/aionui-team/src/service/describe_support.rs index bfbb26b83..00201a5ea 100644 --- a/crates/aionui-team/src/service/describe_support.rs +++ b/crates/aionui-team/src/service/describe_support.rs @@ -76,7 +76,7 @@ fn render_assistant_description( let _ = writeln!(out); let _ = writeln!( out, - "Use `team_spawn_agent` with `custom_agent_id=\"{}\"`.", + "Use `team_spawn_agent` with `assistant_id=\"{}\"`.", definition.assistant_key ); out.trim_end().to_owned() diff --git a/crates/aionui-team/src/service/spawn_support.rs b/crates/aionui-team/src/service/spawn_support.rs index 594a6a3c1..1b0ae5ad0 100644 --- a/crates/aionui-team/src/service/spawn_support.rs +++ b/crates/aionui-team/src/service/spawn_support.rs @@ -59,6 +59,60 @@ pub(crate) fn resolve_full_auto_mode(backend: &str) -> &'static str { } impl TeamSessionService { + pub(crate) async fn resolve_spawn_backend_and_model( + &self, + assistant_id: Option<&str>, + requested_backend: Option<&str>, + requested_model: Option<&str>, + fallback_backend: &str, + fallback_model: &str, + ) -> Result<(String, String), TeamError> { + if let Some(assistant_key) = assistant_id.map(str::trim).filter(|value| !value.is_empty()) { + let definition = self + .assistant_definition_repo + .get_by_key(assistant_key) + .await? + .ok_or_else(|| TeamError::InvalidRequest(format!("Preset assistant not found: {assistant_key}")))?; + let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; + let backend = overlay + .as_ref() + .and_then(|row| row.agent_backend_override.as_deref()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(definition.agent_backend.as_str()) + .to_owned(); + let requested_model = requested_model + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + let fixed_model = (definition.default_model_mode == "fixed") + .then(|| definition.default_model_value.clone()) + .flatten() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()); + let backend_default_model = self.default_model_for_backend(&backend).await; + let model = requested_model + .or(fixed_model) + .or(backend_default_model) + .unwrap_or_else(|| fallback_model.to_owned()); + return Ok((backend, model)); + } + + let backend = requested_backend + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback_backend) + .to_owned(); + let requested_model = requested_model + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + let backend_default_model = self.default_model_for_backend(&backend).await; + let model = requested_model + .or(backend_default_model) + .unwrap_or_else(|| fallback_model.to_owned()); + Ok((backend, model)) + } + /// Check if a backend is allowed to participate in team mode. /// Hard whitelist passes immediately; then checks behavior_policy.supports_team; /// finally queries persisted `agent_capabilities` for MCP transport declarations. @@ -167,6 +221,9 @@ impl TeamSessionService { } pub(crate) async fn default_model_for_backend(&self, backend: &str) -> Option { + if backend == "aionrs" { + return self.collect_provider_models().await.into_iter().next(); + } let row = self.agent_metadata_repo.find_builtin_by_backend(backend).await.ok()??; let json: serde_json::Value = serde_json::from_str(row.available_models.as_deref()?).ok()?; if let Some(id) = json.get("current_model_id").and_then(|v| v.as_str()) @@ -250,6 +307,123 @@ mod tests { use crate::test_utils::workspace_harness::{ force_team_workspace, setup_with_factory_metadata_team_repo_and_conversation_repo, single_agent_team_request, }; + use aionui_db::models::{AssistantDefinitionRow, AssistantOverlayRow, Provider}; + use aionui_db::{ + DbError, IAssistantDefinitionRepository, IAssistantOverlayRepository, IProviderRepository, + UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, + }; + use std::sync::Arc; + + #[derive(Clone)] + struct SingleAssistantDefinitionRepo { + row: AssistantDefinitionRow, + } + + #[async_trait::async_trait] + impl IAssistantDefinitionRepository for SingleAssistantDefinitionRepo { + async fn list(&self) -> Result, DbError> { + Ok(vec![self.row.clone()]) + } + + async fn get_by_key(&self, assistant_key: &str) -> Result, DbError> { + Ok((self.row.assistant_key == assistant_key).then_some(self.row.clone())) + } + + async fn get_by_definition_id(&self, definition_id: &str) -> Result, DbError> { + Ok((self.row.definition_id == definition_id).then_some(self.row.clone())) + } + + async fn get_by_source_ref( + &self, + _source: &str, + _source_ref: &str, + ) -> Result, DbError> { + Ok(None) + } + + async fn upsert( + &self, + _params: &UpsertAssistantDefinitionParams<'_>, + ) -> Result { + Err(DbError::Init("not implemented".into())) + } + + async fn soft_delete(&self, _definition_id: &str, _deleted_at: i64) -> Result { + Ok(false) + } + } + + #[derive(Clone)] + struct SingleAssistantOverlayRepo { + row: AssistantOverlayRow, + } + + #[async_trait::async_trait] + impl IAssistantOverlayRepository for SingleAssistantOverlayRepo { + async fn get(&self, definition_id: &str) -> Result, DbError> { + Ok((self.row.definition_id == definition_id).then_some(self.row.clone())) + } + + async fn list(&self) -> Result, DbError> { + Ok(vec![self.row.clone()]) + } + + async fn upsert(&self, _params: &UpsertAssistantOverlayParams<'_>) -> Result { + Err(DbError::Init("not implemented".into())) + } + + async fn delete(&self, _definition_id: &str) -> Result { + Ok(false) + } + } + + struct SingleProviderRepo { + rows: Vec, + } + + #[async_trait::async_trait] + impl IProviderRepository for SingleProviderRepo { + async fn list(&self) -> Result, DbError> { + Ok(self.rows.clone()) + } + + async fn find_by_id(&self, _id: &str) -> Result, DbError> { + Ok(None) + } + + async fn create(&self, _params: aionui_db::CreateProviderParams<'_>) -> Result { + Err(DbError::NotFound("not implemented".into())) + } + + async fn update(&self, _id: &str, _params: aionui_db::UpdateProviderParams<'_>) -> Result { + Err(DbError::NotFound("not implemented".into())) + } + + async fn delete(&self, _id: &str) -> Result<(), DbError> { + Err(DbError::NotFound("not implemented".into())) + } + } + + fn provider_row(id: &str, models: &[&str]) -> Provider { + Provider { + id: id.into(), + platform: "openai".into(), + name: id.into(), + base_url: "https://example.com".into(), + api_key_encrypted: String::new(), + models: serde_json::to_string(models).unwrap(), + enabled: true, + capabilities: "[]".into(), + context_limit: None, + model_protocols: None, + model_enabled: None, + model_health: None, + bedrock_config: None, + is_full_url: false, + created_at: 0, + updated_at: 0, + } + } #[test] fn parse_agent_type_known_backends() { @@ -316,4 +490,80 @@ mod tests { Some(leader_workspace.as_str()) ); } + + #[tokio::test] + async fn resolve_spawn_backend_and_model_prefers_assistant_identity_over_caller_backend() { + let (svc, _, _, _) = setup_with_factory_metadata_team_repo_and_conversation_repo(); + let svc = TeamSessionService::new( + svc.repo.clone(), + svc.agent_metadata_repo.clone(), + Arc::new(SingleAssistantDefinitionRepo { + row: AssistantDefinitionRow { + definition_id: "def-1".into(), + assistant_key: "word-creator".into(), + source: "builtin".into(), + owner_type: "system".into(), + source_ref: Some("word-creator".into()), + source_version: None, + source_hash: None, + name: "Word Creator".into(), + name_i18n: "{}".into(), + description: None, + description_i18n: "{}".into(), + avatar_type: "emoji".into(), + avatar_value: None, + agent_backend: "aionrs".into(), + rule_resource_type: "inline".into(), + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]".into(), + recommended_prompts_i18n: "{}".into(), + default_model_mode: "auto".into(), + default_model_value: None, + default_permission_mode: "auto".into(), + default_permission_value: None, + default_skills_mode: "auto".into(), + default_skill_ids: "[]".into(), + custom_skill_names: "[]".into(), + default_disabled_builtin_skill_ids: "[]".into(), + default_mcps_mode: "auto".into(), + default_mcp_ids: "[]".into(), + created_at: 0, + updated_at: 0, + deleted_at: None, + }, + }), + Arc::new(SingleAssistantOverlayRepo { + row: AssistantOverlayRow { + definition_id: "def-1".into(), + enabled: true, + sort_order: 0, + agent_backend_override: None, + last_used_at: None, + created_at: 0, + updated_at: 0, + }, + }), + Arc::new(SingleProviderRepo { + rows: vec![provider_row("openai", &["gpt-5-mini"])], + }), + svc.conversation_port.clone(), + svc.projection_store.clone(), + svc.lookup_port.clone(), + svc.broadcaster.clone(), + svc.task_manager.clone(), + svc.turn_port.clone(), + svc.cancellation_port.clone(), + svc.backend_binary_path.clone(), + None, + ); + + let (backend, model) = svc + .resolve_spawn_backend_and_model(Some("word-creator"), None, None, "gemini", "gemini-2.5-pro") + .await + .unwrap(); + + assert_eq!(backend, "aionrs"); + assert_eq!(model, "gpt-5-mini"); + } } diff --git a/crates/aionui-team/src/session.rs b/crates/aionui-team/src/session.rs index 1e8ec6060..a9d179fe3 100644 --- a/crates/aionui-team/src/session.rs +++ b/crates/aionui-team/src/session.rs @@ -994,37 +994,34 @@ impl TeamSession { return Err(TeamError::DuplicateAgentName(requested_name)); } - // Step 3: backend capability check. Hard whitelist passes immediately; + let service = self + .service + .upgrade() + .ok_or_else(|| TeamError::InvalidRequest("spawn_agent requires a live TeamSessionService".into()))?; + + // Step 3: resolve the effective assistant/backend/model target before + // capability checks. Assistant spawns derive backend from the preset + // identity rather than inheriting the caller backend. + let (backend, model) = service + .resolve_spawn_backend_and_model( + req.assistant_id.as_deref(), + req.agent_type.as_deref(), + req.model.as_deref(), + caller.backend.as_str(), + caller.model.as_str(), + ) + .await?; + + // Step 4: backend capability check. Hard whitelist passes immediately; // otherwise query persisted agent_capabilities for MCP support. - let backend = req - .agent_type - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or(caller.backend.as_str()) - .to_owned(); if !crate::guide::capability::TEAM_CAPABLE_BACKENDS.contains(&backend.as_str()) { - let capable = match self.service.upgrade() { - Some(svc) => svc.is_backend_team_capable(&backend).await, - None => false, - }; + let capable = service.is_backend_team_capable(&backend).await; if !capable { return Err(TeamError::BackendNotAllowed(backend)); } } - // Step 4: DB side-effects (new conversation + persisted agent slot). - let service = self - .service - .upgrade() - .ok_or_else(|| TeamError::InvalidRequest("spawn_agent requires a live TeamSessionService".into()))?; - let model = match req.model.as_deref().filter(|m| !m.is_empty()) { - Some(m) => m.to_owned(), - None => service - .default_model_for_backend(&backend) - .await - .unwrap_or_else(|| caller.model.clone()), - }; + // Step 5: DB side-effects (new conversation + persisted agent slot). let new_agent = service .persist_spawned_agent( &self.team.id, From 6709f85d924606e67894f64124f415c0ac3d6c96 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 06:27:13 +0800 Subject: [PATCH 023/135] refactor(team): derive team backends from assistants --- crates/aionui-api-types/src/team.rs | 32 +- crates/aionui-team/src/provisioning.rs | 85 ++++-- crates/aionui-team/src/service.rs | 2 + .../tests/session_service_integration.rs | 285 ++++++++++++++++++ 4 files changed, 380 insertions(+), 24 deletions(-) diff --git a/crates/aionui-api-types/src/team.rs b/crates/aionui-api-types/src/team.rs index e0ba5b042..b69db45da 100644 --- a/crates/aionui-api-types/src/team.rs +++ b/crates/aionui-api-types/src/team.rs @@ -18,6 +18,7 @@ use crate::TeamMcpStdioConfig; pub struct TeamAgentInput { pub name: String, pub role: String, + #[serde(default)] pub backend: String, pub model: String, #[serde(default)] @@ -61,6 +62,7 @@ pub struct RenameTeamRequest { pub struct AddAgentRequest { pub name: String, pub role: String, + #[serde(default)] pub backend: String, pub model: String, #[serde(default)] @@ -487,6 +489,19 @@ mod tests { assert!(input.conversation_id.is_none()); } + #[test] + fn deserialize_team_agent_input_allows_missing_backend_when_assistant_id_present() { + let raw = json!({ + "name": "Lead", + "role": "lead", + "model": "claude", + "assistant_id": "assistant-x" + }); + let input: TeamAgentInput = serde_json::from_value(raw).unwrap(); + assert_eq!(input.backend, ""); + assert_eq!(input.assistant_id.as_deref(), Some("assistant-x")); + } + #[test] fn deserialize_create_team_request_empty_agents() { let raw = json!({ "name": "Empty", "agents": [] }); @@ -576,8 +591,21 @@ mod tests { #[test] fn deserialize_add_agent_request_missing_backend() { let raw = json!({ "name": "X", "role": "teammate", "model": "claude" }); - let result = serde_json::from_value::(raw); - assert!(result.is_err()); + let req = serde_json::from_value::(raw).unwrap(); + assert_eq!(req.backend, ""); + } + + #[test] + fn deserialize_add_agent_request_allows_missing_backend_when_assistant_id_present() { + let raw = json!({ + "name": "X", + "role": "teammate", + "model": "claude", + "assistant_id": "assistant-1" + }); + let req = serde_json::from_value::(raw).unwrap(); + assert_eq!(req.backend, ""); + assert_eq!(req.assistant_id.as_deref(), Some("assistant-1")); } #[test] diff --git a/crates/aionui-team/src/provisioning.rs b/crates/aionui-team/src/provisioning.rs index 867803df8..79ca03795 100644 --- a/crates/aionui-team/src/provisioning.rs +++ b/crates/aionui-team/src/provisioning.rs @@ -4,7 +4,9 @@ use aionui_ai_agent::IWorkerTaskManager; use aionui_api_types::{AddAgentRequest, TeamAgentInput}; use aionui_common::{AgentKillReason, AgentType, ProviderWithModel, generate_id}; use aionui_db::models::TeamRow; -use aionui_db::{IProviderRepository, ITeamRepository, UpdateTeamParams}; +use aionui_db::{ + IAssistantDefinitionRepository, IAssistantOverlayRepository, IProviderRepository, ITeamRepository, UpdateTeamParams, +}; use async_trait::async_trait; use tracing::{info, warn}; @@ -18,6 +20,8 @@ use crate::workspace::TeamWorkspaceResolver; #[derive(Clone)] pub struct TeamAgentProvisioner { repo: Arc, + assistant_definition_repo: Arc, + assistant_overlay_repo: Arc, provider_repo: Arc, conversation_port: Arc, } @@ -106,11 +110,15 @@ impl TeamAgentProvisioner { pub(crate) fn new( repo: Arc, + assistant_definition_repo: Arc, + assistant_overlay_repo: Arc, provider_repo: Arc, conversation_port: Arc, ) -> Self { Self { repo, + assistant_definition_repo, + assistant_overlay_repo, provider_repo, conversation_port, } @@ -133,6 +141,13 @@ impl TeamAgentProvisioner { let leader_slot_id = generate_id(); let leader_role = TeammateRole::Lead; + let leader_assistant_id = Self::effective_assistant_id( + leader_input.assistant_id.as_deref(), + leader_input.custom_agent_id.as_deref(), + ); + let leader_backend = self + .resolve_requested_backend(leader_input.backend.as_str(), leader_assistant_id.as_deref()) + .await?; let leader_conversation = self .create_or_adopt_conversation( user_id, @@ -140,13 +155,9 @@ impl TeamAgentProvisioner { &leader_slot_id, leader_role, &leader_input.name, - &leader_input.backend, + &leader_backend, &leader_input.model, - Self::effective_assistant_id( - leader_input.assistant_id.as_deref(), - leader_input.custom_agent_id.as_deref(), - ) - .as_deref(), + leader_assistant_id.as_deref(), leader_input.conversation_id.as_deref(), shared_workspace, ) @@ -170,12 +181,9 @@ impl TeamAgentProvisioner { name: leader_input.name.clone(), role: leader_role, conversation_id: leader_conversation.conversation_id, - backend: leader_input.backend.clone(), + backend: leader_backend, model: leader_input.model.clone(), - assistant_id: Self::effective_assistant_id( - leader_input.assistant_id.as_deref(), - leader_input.custom_agent_id.as_deref(), - ), + assistant_id: leader_assistant_id, status: None, conversation_type: None, cli_path: None, @@ -184,6 +192,11 @@ impl TeamAgentProvisioner { for input in teammate_inputs { let slot_id = generate_id(); let role = TeammateRole::parse(&input.role).unwrap_or(TeammateRole::Teammate); + let assistant_id = + Self::effective_assistant_id(input.assistant_id.as_deref(), input.custom_agent_id.as_deref()); + let backend = self + .resolve_requested_backend(input.backend.as_str(), assistant_id.as_deref()) + .await?; let conversation = self .create_or_adopt_conversation( user_id, @@ -191,10 +204,9 @@ impl TeamAgentProvisioner { &slot_id, role, &input.name, - &input.backend, + &backend, &input.model, - Self::effective_assistant_id(input.assistant_id.as_deref(), input.custom_agent_id.as_deref()) - .as_deref(), + assistant_id.as_deref(), input.conversation_id.as_deref(), Some(&team_workspace), ) @@ -204,12 +216,9 @@ impl TeamAgentProvisioner { name: input.name.clone(), role, conversation_id: conversation.conversation_id, - backend: input.backend.clone(), + backend, model: input.model.clone(), - assistant_id: Self::effective_assistant_id( - input.assistant_id.as_deref(), - input.custom_agent_id.as_deref(), - ), + assistant_id, status: None, conversation_type: None, cli_path: None, @@ -243,15 +252,19 @@ impl TeamAgentProvisioner { ) -> Result { let role = TeammateRole::parse(&req.role).unwrap_or(TeammateRole::Teammate); let workspace = self.workspace_resolver().resolve_for_new_agent(row, team).await?; + let assistant_id = Self::effective_assistant_id(req.assistant_id.as_deref(), req.custom_agent_id.as_deref()); + let backend = self + .resolve_requested_backend(req.backend.as_str(), assistant_id.as_deref()) + .await?; let agent = self .provision_new_agent(NewAgentProvisioning { user_id: user_id.to_owned(), team_id: team.id.clone(), name: req.name, role, - backend: req.backend, + backend, model: req.model, - assistant_id: Self::effective_assistant_id(req.assistant_id.as_deref(), req.custom_agent_id.as_deref()), + assistant_id, workspace: Some(workspace), }) .await?; @@ -260,6 +273,34 @@ impl TeamAgentProvisioner { Ok(agent) } + async fn resolve_requested_backend( + &self, + requested_backend: &str, + assistant_id: Option<&str>, + ) -> Result { + let requested_backend = requested_backend.trim(); + if !requested_backend.is_empty() { + return Ok(requested_backend.to_owned()); + } + + let Some(assistant_key) = assistant_id.map(str::trim).filter(|value| !value.is_empty()) else { + return Err(TeamError::InvalidRequest( + "backend is required when assistant_id is absent".into(), + )); + }; + + let definition = self + .assistant_definition_repo + .get_by_key(assistant_key) + .await? + .ok_or_else(|| TeamError::InvalidRequest(format!("Preset assistant not found: {assistant_key}")))?; + let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; + Ok(overlay + .and_then(|row| row.agent_backend_override) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(definition.agent_backend)) + } + pub(crate) async fn persist_spawned_agent( &self, user_id: &str, diff --git a/crates/aionui-team/src/service.rs b/crates/aionui-team/src/service.rs index 92ee5f394..9cb4cc764 100644 --- a/crates/aionui-team/src/service.rs +++ b/crates/aionui-team/src/service.rs @@ -120,6 +120,8 @@ impl TeamSessionService { pub(crate) fn provisioner(&self) -> TeamAgentProvisioner { TeamAgentProvisioner::new( self.repo.clone(), + self.assistant_definition_repo.clone(), + self.assistant_overlay_repo.clone(), self.provider_repo.clone(), self.conversation_port.clone(), ) diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index 0e9a3e3ef..3bca82164 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -1077,6 +1077,64 @@ impl IAssistantOverlayRepository for EmptyAssistantOverlayRepo { } } +struct SingleAssistantDefinitionRepo { + row: AssistantDefinitionRow, +} + +#[async_trait::async_trait] +impl IAssistantDefinitionRepository for SingleAssistantDefinitionRepo { + async fn list(&self) -> Result, DbError> { + Ok(vec![self.row.clone()]) + } + + async fn get_by_key(&self, assistant_key: &str) -> Result, DbError> { + Ok((self.row.assistant_key == assistant_key).then_some(self.row.clone())) + } + + async fn get_by_definition_id(&self, definition_id: &str) -> Result, DbError> { + Ok((self.row.definition_id == definition_id).then_some(self.row.clone())) + } + + async fn get_by_source_ref( + &self, + _source: &str, + _source_ref: &str, + ) -> Result, DbError> { + Ok(None) + } + + async fn upsert(&self, _params: &UpsertAssistantDefinitionParams<'_>) -> Result { + Err(DbError::Init("not implemented".into())) + } + + async fn soft_delete(&self, _definition_id: &str, _deleted_at: i64) -> Result { + Ok(false) + } +} + +struct SingleAssistantOverlayRepo { + row: AssistantOverlayRow, +} + +#[async_trait::async_trait] +impl IAssistantOverlayRepository for SingleAssistantOverlayRepo { + async fn get(&self, definition_id: &str) -> Result, DbError> { + Ok((self.row.definition_id == definition_id).then_some(self.row.clone())) + } + + async fn list(&self) -> Result, DbError> { + Ok(vec![self.row.clone()]) + } + + async fn upsert(&self, _params: &UpsertAssistantOverlayParams<'_>) -> Result { + Err(DbError::Init("not implemented".into())) + } + + async fn delete(&self, _definition_id: &str) -> Result { + Ok(false) + } +} + fn setup_with_factory(factory: AgentFactory) -> (Arc, Arc) { setup_with_factory_and_metadata(factory, Arc::new(StubAgentMetadataRepo::empty())) } @@ -1142,6 +1200,48 @@ fn setup_with_factory_metadata_team_repo_and_conversation_repo( (svc, team_repo, task_manager, conv_repo) } +fn setup_with_factory_metadata_assistants_and_conversation_repo( + factory: AgentFactory, + agent_metadata_repo: Arc, + assistant_definition_repo: Arc, + assistant_overlay_repo: Arc, +) -> ( + Arc, + Arc, + Arc, + Arc, +) { + let team_repo = Arc::new(FullMockTeamRepo::new()); + let team_repo_dyn: Arc = team_repo.clone(); + let conv_repo = Arc::new(MockConversationRepo::new()); + let broadcaster: Arc = Arc::new(NullBroadcaster); + let conversation_ports = Arc::new(FakeConversationPorts::new(conv_repo.clone(), broadcaster.clone())); + let conversation_port: Arc = conversation_ports.clone(); + let projection_store: Arc = conversation_ports.clone(); + let lookup_port: Arc = conversation_ports; + let task_manager = Arc::new(CountingTaskManager::new(factory)); + let task_manager_dyn: Arc = task_manager.clone(); + let backend_binary_path = Arc::new(std::path::PathBuf::from("/tmp/aioncore-test")); + let provider_repo: Arc = Arc::new(EmptyProviderRepo); + let svc = TeamSessionService::new( + team_repo_dyn, + agent_metadata_repo, + assistant_definition_repo, + assistant_overlay_repo, + provider_repo, + conversation_port, + projection_store, + lookup_port, + broadcaster, + task_manager_dyn, + noop_turn_port(), + noop_cancellation_port(), + backend_binary_path, + None, + ); + (svc, team_repo, task_manager, conv_repo) +} + fn setup_with_ports_team_repo_and_conversation_repo( factory: AgentFactory, agent_metadata_repo: Arc, @@ -1473,6 +1573,94 @@ async fn tc_create_team_carries_assistant_identity_into_lead_conversation_extra( assert_eq!(extra["preset_assistant_id"], serde_json::json!("2d23ff1c")); } +#[tokio::test] +async fn tc_create_team_derives_backend_from_assistant_when_backend_missing() { + let agent_metadata_repo: Arc = Arc::new(StubAgentMetadataRepo::empty()); + let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { + row: AssistantDefinitionRow { + definition_id: "def-team-lead".into(), + assistant_key: "assistant-lead".into(), + source: "user".into(), + owner_type: "user".into(), + source_ref: None, + source_version: None, + source_hash: None, + name: "Lead Assistant".into(), + name_i18n: "{}".into(), + description: None, + description_i18n: "{}".into(), + avatar_type: "emoji".into(), + avatar_value: Some("🤖".into()), + agent_backend: "claude".into(), + rule_resource_type: "inline".into(), + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]".into(), + recommended_prompts_i18n: "{}".into(), + default_model_mode: "auto".into(), + default_model_value: None, + default_permission_mode: "auto".into(), + default_permission_value: None, + default_skills_mode: "auto".into(), + default_skill_ids: "[]".into(), + custom_skill_names: "[]".into(), + default_disabled_builtin_skill_ids: "[]".into(), + default_mcps_mode: "auto".into(), + default_mcp_ids: "[]".into(), + created_at: 0, + updated_at: 0, + deleted_at: None, + }, + }); + let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { + row: AssistantOverlayRow { + definition_id: "def-team-lead".into(), + enabled: true, + sort_order: 0, + agent_backend_override: Some("codex".into()), + last_used_at: None, + created_at: 0, + updated_at: 0, + }, + }); + let (svc, _team_repo, _task_manager, conv_repo) = setup_with_factory_metadata_assistants_and_conversation_repo( + success_factory(), + agent_metadata_repo, + definition_repo, + overlay_repo, + ); + + let created = svc + .create_team( + "user1", + CreateTeamRequest { + name: "Assistant Lead".into(), + agents: vec![TeamAgentInput { + name: "Lead".into(), + role: "lead".into(), + backend: String::new(), + model: "gpt-5".into(), + assistant_id: Some("assistant-lead".into()), + custom_agent_id: None, + conversation_id: None, + }], + workspace: None, + }, + ) + .await + .unwrap(); + + assert_eq!(created.agents[0].backend, "codex"); + let extra = conv_repo + .get_extra(&created.agents[0].conversation_id) + .expect("lead conversation extra"); + assert_eq!(extra.get("backend").and_then(serde_json::Value::as_str), Some("codex")); + assert_eq!( + extra.get("assistant_id").and_then(serde_json::Value::as_str), + Some("assistant-lead") + ); +} + #[tokio::test] async fn ta_add_agent_uses_model_fallback_for_acp_backend() { let svc = setup_with_metadata_rows(vec![make_agent_metadata_row( @@ -1520,6 +1708,103 @@ async fn ta_add_agent_uses_model_fallback_for_acp_backend() { assert_eq!(added.icon.as_deref(), Some("/api/assets/logos/tools/coding/codex.svg")); } +#[tokio::test] +async fn ta_add_agent_derives_backend_from_assistant_when_backend_missing() { + let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { + row: AssistantDefinitionRow { + definition_id: "def-team-worker".into(), + assistant_key: "assistant-worker".into(), + source: "user".into(), + owner_type: "user".into(), + source_ref: None, + source_version: None, + source_hash: None, + name: "Worker Assistant".into(), + name_i18n: "{}".into(), + description: None, + description_i18n: "{}".into(), + avatar_type: "emoji".into(), + avatar_value: Some("🤖".into()), + agent_backend: "claude".into(), + rule_resource_type: "inline".into(), + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]".into(), + recommended_prompts_i18n: "{}".into(), + default_model_mode: "auto".into(), + default_model_value: None, + default_permission_mode: "auto".into(), + default_permission_value: None, + default_skills_mode: "auto".into(), + default_skill_ids: "[]".into(), + custom_skill_names: "[]".into(), + default_disabled_builtin_skill_ids: "[]".into(), + default_mcps_mode: "auto".into(), + default_mcp_ids: "[]".into(), + created_at: 0, + updated_at: 0, + deleted_at: None, + }, + }); + let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { + row: AssistantOverlayRow { + definition_id: "def-team-worker".into(), + enabled: true, + sort_order: 0, + agent_backend_override: Some("codex".into()), + last_used_at: None, + created_at: 0, + updated_at: 0, + }, + }); + let agent_metadata_repo: Arc = Arc::new(StubAgentMetadataRepo::empty()); + let (svc, _team_repo, _task_manager, _conv_repo) = setup_with_factory_metadata_assistants_and_conversation_repo( + success_factory(), + agent_metadata_repo, + definition_repo, + overlay_repo, + ); + + let team = svc + .create_team( + "user1", + CreateTeamRequest { + name: "Alpha".into(), + agents: vec![TeamAgentInput { + name: "Lead".into(), + role: "lead".into(), + backend: "claude".into(), + model: "claude".into(), + assistant_id: None, + custom_agent_id: None, + conversation_id: None, + }], + workspace: None, + }, + ) + .await + .unwrap(); + + let added = svc + .add_agent( + "user1", + &team.id, + AddAgentRequest { + name: "Worker".into(), + role: "teammate".into(), + backend: String::new(), + model: "gpt-5".into(), + assistant_id: Some("assistant-worker".into()), + custom_agent_id: None, + }, + ) + .await + .unwrap(); + + assert_eq!(added.backend, "codex"); + assert_eq!(added.assistant_id.as_deref(), Some("assistant-worker")); +} + #[tokio::test] async fn tc2_create_single_agent_team() { let svc = setup(); From 712d5fcf1ae840ecfcd7d1b21e1bd787962fff8a Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 06:40:33 +0800 Subject: [PATCH 024/135] refactor(conversation): drop redundant preset extra writes --- crates/aionui-app/tests/conversation_e2e.rs | 9 ++++----- crates/aionui-conversation/src/service.rs | 14 -------------- crates/aionui-conversation/src/service_test.rs | 5 +++-- 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/crates/aionui-app/tests/conversation_e2e.rs b/crates/aionui-app/tests/conversation_e2e.rs index f1abeeef3..3c0d8ac32 100644 --- a/crates/aionui-app/tests/conversation_e2e.rs +++ b/crates/aionui-app/tests/conversation_e2e.rs @@ -224,9 +224,7 @@ async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { "mcp_ids": ["override-mcp"] } }, - "extra": { - "preset_assistant_id": "u1" - } + "extra": {} }), &token, &csrf, @@ -237,8 +235,9 @@ async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { let json = body_json(resp).await; let data = &json["data"]; assert_eq!(data["extra"]["assistant_id"], "u1"); - assert_eq!(data["extra"]["preset_assistant_id"], "u1"); - assert_eq!(data["extra"]["preset_context"], "assistant snapshot rule"); + assert!(data["extra"].get("preset_assistant_id").is_none()); + assert!(data["extra"].get("preset_context").is_none()); + assert!(data["extra"].get("preset_rules").is_none()); assert_eq!(data["extra"]["session_mode"], "workspace-write"); assert_eq!(data["extra"]["current_mode_id"], "workspace-write"); assert_eq!(data["extra"]["current_model_id"], "override-model"); diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 665865834..4f63566d4 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -654,10 +654,6 @@ impl ConversationService { "assistant_id".to_owned(), serde_json::Value::String(snapshot.assistant_id.clone()), ); - obj.insert( - "preset_assistant_id".to_owned(), - serde_json::Value::String(snapshot.assistant_id.clone()), - ); if !snapshot.agent_backend.is_empty() && !obj.contains_key("backend") { obj.insert( "backend".to_owned(), @@ -677,16 +673,6 @@ impl ConversationService { serde_json::Value::String(agent_source.clone()), ); } - if !snapshot.rules.content.is_empty() { - obj.insert( - "preset_context".to_owned(), - serde_json::Value::String(snapshot.rules.content.clone()), - ); - obj.insert( - "preset_rules".to_owned(), - serde_json::Value::String(snapshot.rules.content.clone()), - ); - } if let Some(model_id) = snapshot.resolved_defaults.model.as_ref() && !obj.contains_key("current_model_id") { diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 51e654c27..b215b822c 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -4083,8 +4083,9 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { let resp = svc.create("user-1", req).await.unwrap(); assert_eq!(resp.extra["assistant_id"], json!("preset-1")); - assert_eq!(resp.extra["preset_assistant_id"], json!("preset-1")); - assert_eq!(resp.extra["preset_context"], json!("assistant rule body")); + assert!(resp.extra.get("preset_assistant_id").is_none()); + assert!(resp.extra.get("preset_context").is_none()); + assert!(resp.extra.get("preset_rules").is_none()); assert_eq!(resp.extra["session_mode"], json!("workspace-write")); assert_eq!(resp.extra["current_mode_id"], json!("workspace-write")); assert_eq!(resp.extra["current_model_id"], json!("new-model")); From dfd6d182a9d59228031f978cc6f8dd436548f1e2 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 06:57:40 +0800 Subject: [PATCH 025/135] refactor(team): prefer assistant ids in leader prompts --- crates/aionui-team/docs/team-prompts.md | 10 +++++----- crates/aionui-team/src/prompts/lead.rs | 14 ++++++++------ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/aionui-team/docs/team-prompts.md b/crates/aionui-team/docs/team-prompts.md index ebba2b4ad..570defd0f 100644 --- a/crates/aionui-team/docs/team-prompts.md +++ b/crates/aionui-team/docs/team-prompts.md @@ -134,7 +134,7 @@ Step 10: 分配任务 → team_send_message ``` 1. 扫描 preset 的描述和 skills - └─ 明确匹配 → 直接 team_spawn_agent(custom_agent_id=preset_id) + └─ 明确匹配 → 直接 team_spawn_agent(assistant_id=preset_id) 2. 两个以上可能匹配 └─ 调 team_describe_assistant 对比后选最佳 3. 无匹配 @@ -342,8 +342,8 @@ The new agent will be created and added to the team. You can then assign tasks a **Schema**: ``` name: string — Name for the new teammate (e.g., "researcher", "developer", "tester") -agent_type: string (optional) — Agent type/backend to use for the new teammate. Must be one of the types listed in "Available Agent Types for Spawning". Defaults to the leader type when omitted. Ignored when custom_agent_id is set. -custom_agent_id: string (optional) — Preset assistant ID from "Available Preset Assistants for Spawning" (e.g., "builtin-word-creator"). When set, the teammate inherits that preset's rules and skills; agent_type is derived from the preset. +agent_type: string (optional) — Agent type/backend to use for the new teammate. Must be one of the types listed in "Available Agent Types for Spawning". Defaults to the leader type when omitted. Ignored when assistant_id is set. +assistant_id: string (optional) — Preset assistant ID from "Available Preset Assistants for Spawning" (e.g., "builtin-word-creator"). When set, the teammate inherits that preset's rules and skills; agent_type is derived from the preset. model: string (optional) — Model ID to use for this agent (e.g. "claude-sonnet-4", "gemini-2.5-pro"). Defaults to the backend's preferred model when omitted. ``` @@ -452,12 +452,12 @@ judge whether it fits the user's request. Use this when two or more presets look relevant from the one-line catalog in your system prompt. Only works on preset assistants listed in "Available Preset Assistants for Spawning". -After confirming a match, call team_spawn_agent with the same custom_agent_id. +After confirming a match, call team_spawn_agent with the same assistant_id. ``` **Schema**: ``` -custom_agent_id: string — The preset assistant ID from the "Available Preset Assistants" catalog (e.g., "word-creator"). +assistant_id: string — The preset assistant ID from the "Available Preset Assistants" catalog (e.g., "word-creator"). locale: string (optional) — Locale like "zh-CN" or "en-US". Defaults to the user's current UI language when omitted. ``` diff --git a/crates/aionui-team/src/prompts/lead.rs b/crates/aionui-team/src/prompts/lead.rs index 39d422397..fc99082d5 100644 --- a/crates/aionui-team/src/prompts/lead.rs +++ b/crates/aionui-team/src/prompts/lead.rs @@ -32,11 +32,11 @@ pub struct AvailableAgentType { pub display_name: String, } -/// A preset assistant the leader may spawn via `custom_agent_id`. +/// A preset assistant the leader may spawn via `assistant_id`. /// Phase1 shape per interface-contracts §5 (lines 212-218). #[derive(Debug, Clone, PartialEq, Eq)] pub struct AvailableAssistant { - pub custom_agent_id: String, + pub assistant_id: String, pub name: String, pub backend: String, pub description: String, @@ -151,7 +151,7 @@ fn render_available_assistants_section(assistants: &[AvailableAssistant]) -> Str let _ = write!( out, "- `{}` ({}, backend: {}){}{}", - a.custom_agent_id, a.name, a.backend, desc, skills, + a.assistant_id, a.name, a.backend, desc, skills, ); } out.push_str( @@ -164,7 +164,7 @@ fn render_available_assistants_section(assistants: &[AvailableAssistant]) -> Str fit.\n\ 3. If no preset matches the task, fall back to a generic CLI agent from the \ \"Available Agent Types\" section.\n\n\ - Pass the preset's ID as `custom_agent_id` to `team_spawn_agent`. The `agent_type` is \ + Pass the preset's ID as `assistant_id` to `team_spawn_agent`. The `agent_type` is \ derived from the preset's backend and does not need to be specified.", ); out @@ -230,7 +230,7 @@ mod tests { display_name: "general-purpose AI assistant".into(), }]; let assistants = vec![AvailableAssistant { - custom_agent_id: "word-creator".into(), + assistant_id: "word-creator".into(), name: "Word Creator".into(), backend: "claude".into(), description: "Drafts Word documents".into(), @@ -326,7 +326,7 @@ mod tests { #[test] fn available_assistants_section_includes_skills_and_how_to_pick() { let got = render_available_assistants_section(&[AvailableAssistant { - custom_agent_id: "word-creator".into(), + assistant_id: "word-creator".into(), name: "Word Creator".into(), backend: "claude".into(), description: "Drafts Word documents".into(), @@ -336,6 +336,8 @@ mod tests { assert!(got.contains("- `word-creator` (Word Creator, backend: claude) — Drafts Word documents")); assert!(got.contains("skills: docx, formatting")); assert!(got.contains("### How to pick a preset")); + assert!(got.contains("Pass the preset's ID as `assistant_id`")); + assert!(!got.contains("custom_agent_id")); } #[test] From f71aa964cf6ef3d3261c6db088a77c41a50f9545 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 07:10:52 +0800 Subject: [PATCH 026/135] refactor(channel): create conversations through assistant identities --- crates/aionui-channel/src/channel_settings.rs | 15 +++ crates/aionui-channel/src/message_service.rs | 11 +- .../tests/message_service_integration.rs | 123 +++++++++++++++++- 3 files changed, 145 insertions(+), 4 deletions(-) diff --git a/crates/aionui-channel/src/channel_settings.rs b/crates/aionui-channel/src/channel_settings.rs index cee924d50..4ac3de819 100644 --- a/crates/aionui-channel/src/channel_settings.rs +++ b/crates/aionui-channel/src/channel_settings.rs @@ -172,6 +172,21 @@ impl ChannelSettingsService { }) } + pub async fn get_assistant_setting( + &self, + platform: PluginType, + ) -> Result, ChannelError> { + let key = agent_key(platform); + let prefs = self.pref_repo.get_by_keys(&[&key]).await?; + + let Some(pref) = prefs.into_iter().next() else { + return Ok(None); + }; + + Ok(parse_channel_assistant_setting(&pref.value) + .map(|assistant| normalize_channel_assistant_setting_for_write(&assistant))) + } + pub async fn set_assistant_setting( &self, platform: PluginType, diff --git a/crates/aionui-channel/src/message_service.rs b/crates/aionui-channel/src/message_service.rs index f76147875..32fe5c649 100644 --- a/crates/aionui-channel/src/message_service.rs +++ b/crates/aionui-channel/src/message_service.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use aionui_ai_agent::{AgentStreamEvent, IWorkerTaskManager}; -use aionui_api_types::{CreateConversationRequest, SendMessageRequest}; +use aionui_api_types::{AssistantConversationRequest, CreateConversationRequest, SendMessageRequest}; use aionui_common::{AgentType, ConversationSource}; use aionui_conversation::ConversationService; use aionui_db::models::AssistantSessionRow; @@ -128,6 +128,7 @@ impl ChannelMessageService { let agent_type = parse_agent_type(&session.agent_type)?; let agent_config = self.settings.get_agent_config(platform).await?; + let assistant_setting = self.settings.get_assistant_setting(platform).await?; let model_config = self.settings.get_model_config(platform).await?; let model = resolved_model_to_provider(model_config.as_ref()); let mut extra = Self::build_channel_extra(agent_config.backend.as_deref()); @@ -150,7 +151,13 @@ impl ChannelMessageService { r#type: agent_type, name: Some(name), model: top_level_model, - assistant: None, + assistant: assistant_setting + .and_then(|setting| setting.assistant_id) + .map(|assistant_id| AssistantConversationRequest { + id: assistant_id, + locale: None, + conversation_overrides: None, + }), source: Some(source), channel_chat_id: session.chat_id.clone(), extra, diff --git a/crates/aionui-channel/tests/message_service_integration.rs b/crates/aionui-channel/tests/message_service_integration.rs index 5cb32cfd8..c03907d07 100644 --- a/crates/aionui-channel/tests/message_service_integration.rs +++ b/crates/aionui-channel/tests/message_service_integration.rs @@ -12,9 +12,12 @@ use aionui_common::{AgentKillReason, AgentType, ConversationStatus, TimestampMs} use aionui_conversation::ConversationService; use aionui_conversation::skill_resolver::{ResolvedAgentSkill, SkillResolver}; use aionui_db::models::AssistantSessionRow; +use aionui_db::models::UpsertAssistantDefinitionParams; use aionui_db::{ - SqliteAcpSessionRepository, SqliteAgentMetadataRepository, SqliteClientPreferenceRepository, - SqliteConversationRepository, init_database_memory, + IAssistantDefinitionRepository, IClientPreferenceRepository, IConversationRepository, SqliteAcpSessionRepository, + SqliteAgentMetadataRepository, SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, + SqliteAssistantPreferenceRepository, SqliteClientPreferenceRepository, SqliteConversationRepository, + init_database_memory, }; use aionui_realtime::EventBroadcaster; use async_trait::async_trait; @@ -177,6 +180,44 @@ impl IWorkerTaskManager for RecordingTaskManager { } } +fn bare_assistant_definition_params<'a>( + definition_id: &'a str, + assistant_key: &'a str, + agent_backend: &'a str, +) -> UpsertAssistantDefinitionParams<'a> { + UpsertAssistantDefinitionParams { + definition_id, + assistant_key, + source: "generated", + owner_type: "system", + source_ref: Some(assistant_key), + source_version: None, + source_hash: None, + name: assistant_key, + name_i18n: "{}", + description: Some("Channel bare assistant"), + description_i18n: "{}", + avatar_type: "emoji", + avatar_value: Some("🤖"), + agent_backend, + rule_resource_type: "inline", + rule_resource_ref: None, + rule_inline_content: Some(""), + recommended_prompts: "[]", + recommended_prompts_i18n: "{}", + default_model_mode: "auto", + default_model_value: None, + default_permission_mode: "auto", + default_permission_value: None, + default_skills_mode: "auto", + default_skill_ids: "[]", + custom_skill_names: "[]", + default_disabled_builtin_skill_ids: "[]", + default_mcps_mode: "auto", + default_mcp_ids: "[]", + } +} + #[tokio::test] async fn send_to_agent_warms_cold_task_before_returning_stream_subscription() { let db = init_database_memory().await.unwrap(); @@ -229,3 +270,81 @@ async fn send_to_agent_warms_cold_task_before_returning_stream_subscription() { assert!(task_manager.get_task(&result.conversation_id).is_some()); } } + +#[tokio::test] +async fn send_to_agent_persists_assistant_snapshot_for_channel_bound_assistant() { + let db = init_database_memory().await.unwrap(); + let pool = db.pool().clone(); + + let task_manager: Arc = Arc::new(RecordingTaskManager::new()); + let conversation_repo = Arc::new(SqliteConversationRepository::new(pool.clone())); + let conversation_repo_trait: Arc = conversation_repo.clone(); + let conversation_svc = Arc::new(ConversationService::new( + std::env::temp_dir(), + Arc::new(TestBroadcaster::new()), + Arc::new(NoopSkillResolver), + Arc::clone(&task_manager), + conversation_repo_trait, + Arc::new(SqliteAgentMetadataRepository::new(pool.clone())), + Arc::new(SqliteAcpSessionRepository::new(pool.clone())), + )); + + let pref_repo = Arc::new(SqliteClientPreferenceRepository::new(pool.clone())); + let definition_repo = Arc::new(SqliteAssistantDefinitionRepository::new(pool.clone())); + let overlay_repo = Arc::new(SqliteAssistantOverlayRepository::new(pool.clone())); + let assistant_preference_repo = Arc::new(SqliteAssistantPreferenceRepository::new(pool.clone())); + conversation_svc.with_assistant_definition_repo(definition_repo.clone()); + conversation_svc.with_assistant_state_repo(overlay_repo.clone()); + conversation_svc.with_assistant_preference_repo(assistant_preference_repo); + definition_repo + .upsert(&bare_assistant_definition_params( + "asstdef-channel-claude", + "bare-claude", + "claude", + )) + .await + .unwrap(); + pref_repo + .upsert_batch(&[( + "assistant.telegram.agent", + r#"{"assistant_id":"bare-claude","name":"Claude"}"#, + )]) + .await + .unwrap(); + + let settings = Arc::new(ChannelSettingsService::new(pref_repo).with_assistant_repos(definition_repo, overlay_repo)); + let message_svc = ChannelMessageService::new( + conversation_svc, + Arc::clone(&task_manager), + settings, + "system_default_user".to_owned(), + ); + + let session = AssistantSessionRow { + id: "session-assisted".to_owned(), + user_id: "channel-user-1".to_owned(), + agent_type: "acp".to_owned(), + conversation_id: None, + workspace: None, + chat_id: Some("7088048016".to_owned()), + created_at: 1, + last_activity: 1, + }; + + let result = message_svc + .send_to_agent(&session, "hello", PluginType::Telegram) + .await + .unwrap(); + + let snapshot = conversation_repo + .get_assistant_snapshot(&result.conversation_id) + .await + .unwrap(); + assert!( + snapshot.is_some(), + "channel-created conversation should persist an assistant snapshot when the platform is bound to an assistant" + ); + let snapshot = snapshot.unwrap(); + assert_eq!(snapshot.assistant_key, "bare-claude"); + assert_eq!(snapshot.agent_backend, "claude"); +} From f5ebc0a694a1363cb4f73900a86751b666a2a316 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 07:36:24 +0800 Subject: [PATCH 027/135] refactor(team): seed lead prompts from assistants --- crates/aionui-team/src/prompts/mod.rs | 81 ++++++----- .../aionui-team/src/service/spawn_support.rs | 128 +++++++++++------- crates/aionui-team/src/session.rs | 38 ++++-- .../tests/prompts_events_integration.rs | 36 +++-- 4 files changed, 168 insertions(+), 115 deletions(-) diff --git a/crates/aionui-team/src/prompts/mod.rs b/crates/aionui-team/src/prompts/mod.rs index 1dd6d5549..55145fe34 100644 --- a/crates/aionui-team/src/prompts/mod.rs +++ b/crates/aionui-team/src/prompts/mod.rs @@ -1,12 +1,13 @@ pub mod lead; pub mod team_guide; +pub use lead::AvailableAssistant; pub use team_guide::{TEAM_GUIDE_PROMPT_TEMPLATE, build_team_guide_prompt}; pub mod teammate; use std::collections::HashMap; -use crate::prompts::lead::{AvailableAgentType, LeadPromptParams}; +use crate::prompts::lead::LeadPromptParams; use crate::types::{MailboxMessage, MailboxMessageType, TaskStatus, TeamAgent, TeamTask}; /// Build the leader system prompt. @@ -17,24 +18,21 @@ use crate::types::{MailboxMessage, MailboxMessageType, TaskStatus, TeamAgent, Te /// surfaces this through other channels, but the backend session has no /// other place to inject it). /// -/// `available_agent_types` carries `(backend_id, display_name)` pairs that -/// feed the `## Available Agent Types for Spawning` section; callers -/// should source these from the team-capable backend whitelist. -pub fn build_lead_prompt(team_name: &str, members: &[TeamAgent], available_agent_types: &[(String, String)]) -> String { - let agent_types: Vec = available_agent_types - .iter() - .map(|(backend, display)| AvailableAgentType { - agent_type: backend.clone(), - display_name: display.clone(), - }) - .collect(); +/// `available_assistants` is the assistant catalog the leader may use when +/// staffing the team. Callers should only include assistants that are both +/// enabled and team-selectable. +pub fn build_lead_prompt( + team_name: &str, + members: &[TeamAgent], + available_assistants: &[AvailableAssistant], +) -> String { let renamed: HashMap = HashMap::new(); let params = LeadPromptParams { team_name, teammates: members, - available_agent_types: &agent_types, - available_assistants: &[], + available_agent_types: &[], + available_assistants, renamed_agents: &renamed, team_workspace: None, }; @@ -200,26 +198,28 @@ mod tests { // -- Lead prompt ---------------------------------------------------------- - fn default_agent_types() -> Vec<(String, String)> { - vec![ - ("claude".into(), "Claude".into()), - ("codex".into(), "Codex".into()), - ("gemini".into(), "Gemini".into()), - ] + fn default_assistants() -> Vec { + vec![AvailableAssistant { + assistant_id: "word-creator".into(), + name: "Word Creator".into(), + backend: "claude".into(), + description: "Drafts Word documents".into(), + skills: vec!["docx".into(), "formatting".into()], + }] } #[test] fn lead_prompt_contains_team_name() { - let types = default_agent_types(); - let prompt = build_lead_prompt("Alpha", &[], &types); + let assistants = default_assistants(); + let prompt = build_lead_prompt("Alpha", &[], &assistants); assert!(prompt.contains("\"Alpha\"")); } #[test] fn lead_prompt_contains_member_list() { - let types = default_agent_types(); + let assistants = default_assistants(); let members = vec![make_lead(), make_teammate("w1", "Worker1")]; - let prompt = build_lead_prompt("Alpha", &members, &types); + let prompt = build_lead_prompt("Alpha", &members, &assistants); // AionUi bullet format: `- {name} ({backend}, status: {status})` assert!(prompt.contains("- Lead (acp, status:")); @@ -228,8 +228,8 @@ mod tests { #[test] fn lead_prompt_contains_core_sections() { - let types = default_agent_types(); - let prompt = build_lead_prompt("Alpha", &[], &types); + let assistants = default_assistants(); + let prompt = build_lead_prompt("Alpha", &[], &assistants); // Workflow — 15-step procedure with model listing at step 3 assert!(prompt.contains("## Workflow")); @@ -262,36 +262,35 @@ mod tests { } #[test] - fn lead_prompt_includes_available_agent_types_section() { - let types = default_agent_types(); - let prompt = build_lead_prompt("Alpha", &[], &types); - - assert!(prompt.contains("## Available Agent Types for Spawning")); - assert!(prompt.contains("- `claude` — Claude")); - assert!(prompt.contains("- `codex` — Codex")); - assert!(prompt.contains("- `gemini` — Gemini")); - assert!(prompt.contains("Use `team_list_models`")); + fn lead_prompt_includes_available_assistants_section() { + let assistants = default_assistants(); + let prompt = build_lead_prompt("Alpha", &[], &assistants); + + assert!(prompt.contains("## Available Preset Assistants for Spawning")); + assert!(prompt.contains("- `word-creator` (Word Creator, backend: claude) — Drafts Word documents")); + assert!(prompt.contains("skills: docx, formatting")); + assert!(prompt.contains("Pass the preset's ID as `assistant_id`")); } #[test] - fn lead_prompt_omits_agent_types_section_when_empty() { + fn lead_prompt_omits_available_assistants_section_when_empty() { let prompt = build_lead_prompt("Alpha", &[], &[]); - assert!(!prompt.contains("## Available Agent Types for Spawning")); + assert!(!prompt.contains("## Available Preset Assistants for Spawning")); } #[test] fn lead_prompt_no_members_shows_empty_lineup_copy() { - let types = default_agent_types(); - let prompt = build_lead_prompt("Solo", &[], &types); + let assistants = default_assistants(); + let prompt = build_lead_prompt("Solo", &[], &assistants); assert!(prompt.contains("(no teammates yet")); assert!(prompt.contains("propose the lineup to the user first")); } #[test] fn lead_prompt_has_no_unsubstituted_placeholders() { - let types = default_agent_types(); + let assistants = default_assistants(); let members = vec![make_lead(), make_teammate("w1", "Worker1")]; - let prompt = build_lead_prompt("Alpha", &members, &types); + let prompt = build_lead_prompt("Alpha", &members, &assistants); assert!( !prompt.contains("${"), "unsubstituted template placeholder leaked:\n{prompt}" diff --git a/crates/aionui-team/src/service/spawn_support.rs b/crates/aionui-team/src/service/spawn_support.rs index 1b0ae5ad0..3ca452a03 100644 --- a/crates/aionui-team/src/service/spawn_support.rs +++ b/crates/aionui-team/src/service/spawn_support.rs @@ -2,6 +2,10 @@ use super::*; use aionui_api_types::BehaviorPolicy; use aionui_common::AgentType; use aionui_common::constants::{TEAM_CAPABLE_BACKENDS, has_mcp_capability}; +use aionui_db::models::AssistantOverlayRow; +use std::collections::HashMap; + +use crate::prompts::AvailableAssistant; /// Known ACP vendor labels. Kept in lockstep with the `agent_metadata` /// seed in `005_agent_metadata.sql` — a caller hitting an unknown @@ -138,61 +142,87 @@ impl TeamSessionService { has_mcp_capability(caps.as_ref()) } - /// Return all backends currently team-capable (hard whitelist + behavior_policy + dynamically detected). - /// Used to build the Lead prompt's `available_agent_types` list. - pub(crate) async fn list_team_capable_backends(&self) -> Vec<(String, String)> { - let Ok(rows) = self.agent_metadata_repo.list_all().await else { - return TEAM_CAPABLE_BACKENDS - .iter() - .map(|b| (b.to_string(), capitalize(b))) - .collect(); + /// Return all enabled assistants that can currently participate in team mode. + /// This is the assistant-first candidate source for the leader prompt. + pub(crate) async fn list_team_selectable_assistants(&self) -> Vec { + let Ok(definitions) = self.assistant_definition_repo.list().await else { + return Vec::new(); + }; + let Ok(overlays) = self.assistant_overlay_repo.list().await else { + return Vec::new(); }; - let mut result: Vec<(String, String)> = Vec::new(); - for row in &rows { - if !row.enabled { + let Ok(agent_rows) = self.agent_metadata_repo.list_all().await else { + return Vec::new(); + }; + + let overlay_by_definition: HashMap<&str, &AssistantOverlayRow> = + overlays.iter().map(|row| (row.definition_id.as_str(), row)).collect(); + + let mut assistants: Vec<(i32, AvailableAssistant)> = Vec::new(); + + for definition in &definitions { + let Some(source) = (match definition.source.as_str() { + "builtin" | "generated" | "user" => Some(definition.source.as_str()), + _ => None, + }) else { continue; - } - // Use backend if present, otherwise agent_type as identifier - let key = match row.backend.as_deref() { - Some(b) => b.to_string(), - None => row.agent_type.clone(), }; - - // Check behavior_policy.supports_team (covers agents with backend=NULL like aionrs) - let bp_supports = row - .behavior_policy - .as_deref() - .and_then(|s| serde_json::from_str::(s).ok()) - .is_some_and(|bp| bp.supports_team); - if bp_supports { - result.push((key, row.name.clone())); + let overlay = overlay_by_definition.get(definition.definition_id.as_str()).copied(); + let enabled = overlay.is_none_or(|row| row.enabled); + if !enabled { continue; } - // Hard whitelist (only works when backend is present) - if let Some(backend) = row.backend.as_deref() - && TEAM_CAPABLE_BACKENDS.contains(&backend) - { - result.push((key, row.name.clone())); + let effective_backend = overlay + .and_then(|row| row.agent_backend_override.as_deref()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(definition.agent_backend.as_str()); + + let agent_row = if source == "generated" { + definition + .source_ref + .as_deref() + .and_then(|source_ref| agent_rows.iter().find(|row| row.id == source_ref)) + } else { + agent_rows + .iter() + .find(|row| row.backend.as_deref() == Some(effective_backend) && row.agent_source != "custom") + .or_else(|| { + agent_rows + .iter() + .find(|row| row.backend.as_deref() == Some(effective_backend)) + }) + }; + + let is_available = agent_row.is_some_and(|row| row.last_check_status.as_deref() != Some("unavailable")); + let is_team_capable = self.is_backend_team_capable(effective_backend).await; + if !(is_available && is_team_capable) { continue; } - // Dynamic MCP detection - let caps = row - .agent_capabilities - .as_deref() - .and_then(|s| serde_json::from_str::(s).ok()); - if has_mcp_capability(caps.as_ref()) { - result.push((key, row.name.clone())); - } - } - // Ensure hard whitelist entries are present even if not in DB - for &b in TEAM_CAPABLE_BACKENDS { - if !result.iter().any(|(bk, _)| bk == b) { - result.push((b.to_string(), capitalize(b))); - } + let mut skills = decode_string_list(&definition.default_skill_ids); + skills.extend(decode_string_list(&definition.custom_skill_names)); + + assistants.push(( + overlay.map(|row| row.sort_order).unwrap_or(i32::MAX), + AvailableAssistant { + assistant_id: definition.assistant_key.clone(), + name: definition.name.clone(), + backend: effective_backend.to_owned(), + description: definition.description.clone().unwrap_or_default(), + skills, + }, + )); } - result + + assistants.sort_by(|(left_order, left), (right_order, right)| { + left_order + .cmp(right_order) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.assistant_id.cmp(&right.assistant_id)) + }); + + assistants.into_iter().map(|(_, assistant)| assistant).collect() } /// Return the `team_list_models` response built from DB rows. @@ -293,12 +323,8 @@ impl TeamSessionService { } } -fn capitalize(s: &str) -> String { - let mut c = s.chars(); - match c.next() { - None => String::new(), - Some(f) => f.to_uppercase().collect::() + c.as_str(), - } +fn decode_string_list(raw: &str) -> Vec { + serde_json::from_str::>(raw).unwrap_or_default() } #[cfg(test)] diff --git a/crates/aionui-team/src/session.rs b/crates/aionui-team/src/session.rs index a9d179fe3..eab0b6921 100644 --- a/crates/aionui-team/src/session.rs +++ b/crates/aionui-team/src/session.rs @@ -252,24 +252,14 @@ impl TeamSession { let first_message = if needs_role_prompt { let role_prompt = match agent.role { TeammateRole::Lead => { - let available_agent_types = match self.service.upgrade() { - Some(svc) => svc.list_team_capable_backends().await, - None => crate::guide::capability::TEAM_CAPABLE_BACKENDS - .iter() - .map(|b| { - let mut c = b.chars(); - let display = match c.next() { - None => String::new(), - Some(f) => f.to_uppercase().collect::() + c.as_str(), - }; - (b.to_string(), display) - }) - .collect(), + let available_assistants = match self.service.upgrade() { + Some(svc) => svc.list_team_selectable_assistants().await, + None => Vec::new(), }; build_lead_prompt( &self.team.name, &self.scheduler.list_agents().await, - &available_agent_types, + &available_assistants, ) } TeammateRole::Teammate => build_teammate_prompt(&agent, &self.team.name), @@ -994,6 +984,26 @@ impl TeamSession { return Err(TeamError::DuplicateAgentName(requested_name)); } + if req.assistant_id.is_none() { + let candidate_backend = req + .agent_type + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(caller.backend.as_str()) + .to_owned(); + + if crate::service::spawn_support::parse_agent_type(&candidate_backend).is_err() { + return Err(TeamError::BackendNotAllowed(candidate_backend)); + } + + if !crate::guide::capability::TEAM_CAPABLE_BACKENDS.contains(&candidate_backend.as_str()) + && self.service.upgrade().is_none() + { + return Err(TeamError::BackendNotAllowed(candidate_backend)); + } + } + let service = self .service .upgrade() diff --git a/crates/aionui-team/tests/prompts_events_integration.rs b/crates/aionui-team/tests/prompts_events_integration.rs index e488ab583..74f0d376a 100644 --- a/crates/aionui-team/tests/prompts_events_integration.rs +++ b/crates/aionui-team/tests/prompts_events_integration.rs @@ -12,7 +12,7 @@ use aionui_team::message_projection::{ ProjectedTeamMessage, TeamMessageProjection, TeamProjectionMessageStore, TeamProjectionRequest, TeamProjectionSource, }; -use aionui_team::prompts::{build_lead_prompt, build_teammate_prompt, build_wake_payload}; +use aionui_team::prompts::{AvailableAssistant, build_lead_prompt, build_teammate_prompt, build_wake_payload}; use aionui_team::types::{ MailboxMessage, MailboxMessageType, TaskStatus, TeamAgent, TeamTask, TeammateRole, TeammateStatus, }; @@ -213,11 +213,29 @@ async fn projection_dedupes_teammate_mirror_and_broadcasts_persisted_msg_id() { // Test-plan §9: Prompt Templates // =========================================================================== -fn default_agent_types() -> Vec<(String, String)> { +fn default_assistants() -> Vec { vec![ - ("claude".into(), "Claude".into()), - ("codex".into(), "Codex".into()), - ("gemini".into(), "Gemini".into()), + AvailableAssistant { + assistant_id: "research-assistant".into(), + name: "Research Assistant".into(), + backend: "claude".into(), + description: "General-purpose research assistant".into(), + skills: vec!["web-search".into(), "synthesis".into()], + }, + AvailableAssistant { + assistant_id: "writer-assistant".into(), + name: "Writer Assistant".into(), + backend: "codex".into(), + description: "Writing-focused assistant".into(), + skills: vec!["drafting".into()], + }, + AvailableAssistant { + assistant_id: "slides-assistant".into(), + name: "Slides Assistant".into(), + backend: "gemini".into(), + description: "Presentation builder".into(), + skills: vec!["slides".into()], + }, ] } @@ -230,8 +248,8 @@ fn lp1_lead_prompt_contains_member_list() { make_agent("w1", "Alice", TeammateRole::Teammate), make_agent("w2", "Bob", TeammateRole::Teammate), ]; - let types = default_agent_types(); - let prompt = build_lead_prompt("Alpha", &members, &types); + let assistants = default_assistants(); + let prompt = build_lead_prompt("Alpha", &members, &assistants); // AionUi bullet format: `- {name} ({backend}, status: {status})` assert!(prompt.contains("- Lead ("), "lead name missing"); @@ -243,7 +261,7 @@ fn lp1_lead_prompt_contains_member_list() { #[test] fn lp2_lead_prompt_contains_tool_descriptions() { - let prompt = build_lead_prompt("Beta", &[], &default_agent_types()); + let prompt = build_lead_prompt("Beta", &[], &default_assistants()); // AionUi lead prompt references the `team_*` coordination tools that the // leader must use; the MCP layer enumerates them with arguments, so the @@ -267,7 +285,7 @@ fn lp2_lead_prompt_contains_tool_descriptions() { #[test] fn lp3_lead_prompt_contains_task_management_guidance() { - let prompt = build_lead_prompt("Gamma", &[], &default_agent_types()); + let prompt = build_lead_prompt("Gamma", &[], &default_assistants()); assert!( prompt.contains("Break the work into tasks"), From 16d53edc90c4517aee9b0bc80d57e3a42ec40605 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 07:39:25 +0800 Subject: [PATCH 028/135] refactor(team): reword mcp tools around assistants --- crates/aionui-team/src/mcp/tools.rs | 49 +++++++++++++++++++---------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/crates/aionui-team/src/mcp/tools.rs b/crates/aionui-team/src/mcp/tools.rs index 09986b564..8248de611 100644 --- a/crates/aionui-team/src/mcp/tools.rs +++ b/crates/aionui-team/src/mcp/tools.rs @@ -21,25 +21,27 @@ Use this only when one of the following is true: Before calling this tool in the normal planning flow: - Start with one short sentence explaining why additional teammates would help - Tell the user which teammate(s) you recommend -- Present the proposal as a table with: name, responsibility, recommended agent type/backend, and recommended model -- Include each teammate's responsibility, recommended agent type/backend, and model -- Ask whether to create them as proposed or change any names, responsibilities, or agent types +- Present the proposal as a table with: name, responsibility, recommended assistant or backend, and recommended model +- Include each teammate's responsibility, recommended assistant or backend, and model +- Ask whether to create them as proposed or change any names, responsibilities, or assistant choices - In that approval question, remind the user that they can later ask you to replace or adjust any teammate if the lineup is not working well - Do NOT call this tool in that same turn; wait for explicit approval in a later user message +When a preset assistant is a good fit, prefer passing assistant_id. Use agent_type/backend only as a compatibility fallback when no preset assistant fits the requested role. + When calling this tool, provide the model parameter if a specific model was recommended and approved. The new agent will be created and added to the team. You can then assign tasks and send messages to it."#; /// Description for `team_list_models` — verbatim from team-prompts.md §5.2. -pub const TEAM_LIST_MODELS_DESCRIPTION: &str = "Query available models for team agent types. Returns the real-time model list that matches the frontend model selector. +pub const TEAM_LIST_MODELS_DESCRIPTION: &str = "Query available models for assistant backends. Returns the real-time model list that matches the frontend model selector. Use this to: -- Check what models are available before spawning an agent with a specific model -- See all available agent types and their models at once -- Verify a model ID is valid for a given agent type +- Check what models are available before spawning an assistant-backed teammate with a specific model +- See all available backends and their models at once +- Verify a model ID is valid for the backend behind a chosen assistant or fallback backend -Pass agent_type to query a specific backend, or omit it to see all."; +Pass agent_type to query a specific backend, or omit it to see all backends."; /// Description for `team_describe_assistant` — verbatim from team-prompts.md §5.2. pub const TEAM_DESCRIBE_ASSISTANT_DESCRIPTION: &str = @@ -84,10 +86,10 @@ pub fn all_tool_descriptors() -> Vec { "type": "object", "properties": { "name": { "type": "string", "description": "Agent display name" }, - "agent_type": { "type": "string", "description": "Agent type/backend to use (e.g. \"claude\", \"codex\", \"codebuddy\", \"gemini\"). Query team_list_models first to see available options." }, - "model": { "type": "string", "description": "Specific model ID to use (e.g. \"claude-sonnet-4\"). Must be a valid model for the chosen agent_type. Query team_list_models to see available models." }, - "assistant_id": { "type": "string", "description": "Preset assistant ID to spawn (from the Available Preset Assistants catalog). When set, agent_type is derived from the preset's backend." }, - "backend": { "type": "string", "description": "Legacy alias for agent_type. Prefer agent_type." }, + "agent_type": { "type": "string", "description": "Fallback backend to use (e.g. \"claude\", \"codex\", \"codebuddy\", \"gemini\") when no preset assistant fits. Query team_list_models first to see available options." }, + "model": { "type": "string", "description": "Specific model ID to use (e.g. \"claude-sonnet-4\"). Must be valid for the chosen assistant backend or fallback agent_type. Query team_list_models to see available models." }, + "assistant_id": { "type": "string", "description": "Preferred preset assistant ID to spawn (from the Available Preset Assistants catalog). When set, agent_type is derived from the preset's backend." }, + "backend": { "type": "string", "description": "Legacy alias for agent_type. Prefer assistant_id first, then agent_type." }, "role": { "type": "string", "description": "Agent role (default: 'teammate')" } }, "required": ["name"] @@ -181,7 +183,7 @@ pub fn all_tool_descriptors() -> Vec { input_schema: json!({ "type": "object", "properties": { - "agent_type": { "type": "string", "description": "Agent type/backend to query (e.g. \"gemini\", \"claude\", \"codex\"). Shows all when omitted." } + "agent_type": { "type": "string", "description": "Backend to query (e.g. \"gemini\", \"claude\", \"codex\"). Shows all backends when omitted." } } }), }, @@ -668,7 +670,7 @@ mod tests { assert!(result.unwrap_err().contains("handled directly by server")); } - // ---- D4 descriptor text matches team-prompts.md §5.2 verbatim ---- + // ---- D4 descriptor text remains aligned with assistant-first MCP contract ---- #[test] fn team_list_models_descriptor_text_matches() { @@ -679,11 +681,11 @@ mod tests { assert_eq!(desc.description, TEAM_LIST_MODELS_DESCRIPTION); assert!( desc.description - .starts_with("Query available models for team agent types.") + .starts_with("Query available models for assistant backends.") ); assert!( desc.description - .contains("Pass agent_type to query a specific backend, or omit it to see all.") + .contains("Pass agent_type to query a specific backend, or omit it to see all backends.") ); } @@ -730,6 +732,21 @@ mod tests { assert_eq!(input.assistant_id.as_deref(), Some("word-creator")); } + #[test] + fn team_spawn_agent_schema_prefers_assistant_id_over_agent_type() { + let desc = all_tool_descriptors() + .into_iter() + .find(|d| d.name == "team_spawn_agent") + .unwrap(); + let props = desc.input_schema["properties"].as_object().unwrap(); + let assistant_desc = props["assistant_id"]["description"].as_str().unwrap(); + let agent_type_desc = props["agent_type"]["description"].as_str().unwrap(); + let backend_desc = props["backend"]["description"].as_str().unwrap(); + assert!(assistant_desc.starts_with("Preferred preset assistant ID")); + assert!(agent_type_desc.starts_with("Fallback backend to use")); + assert!(backend_desc.contains("Prefer assistant_id first")); + } + // ---- D4 handlers return non-error payloads ---- #[test] From fe74d85858e0f652c1a7959b16cc4a8fa5bf42ec Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 07:41:58 +0800 Subject: [PATCH 029/135] refactor(team): reword prompts around assistants --- crates/aionui-team/src/prompts/lead.rs | 18 ++++++++++-------- .../src/prompts/prompt_templates/lead.txt | 16 ++++++++-------- crates/aionui-team/src/prompts/team_guide.rs | 16 ++++++++-------- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/crates/aionui-team/src/prompts/lead.rs b/crates/aionui-team/src/prompts/lead.rs index fc99082d5..236f1f701 100644 --- a/crates/aionui-team/src/prompts/lead.rs +++ b/crates/aionui-team/src/prompts/lead.rs @@ -24,7 +24,7 @@ const PLACEHOLDER_WORKSPACE_SECTION: &str = "${workspaceSection}"; const PLACEHOLDER_PRESET_FORMATTING_STEP_RULE: &str = "${presetFormattingStepRule}"; const PLACEHOLDER_PRESET_FORMATTING_IMPORTANT_RULE: &str = "${presetFormattingImportantRule}"; -/// A generic agent type (CLI backend) that the leader may spawn. +/// A generic backend fallback that the leader may spawn when no preset assistant fits. /// Phase1 shape per interface-contracts §5 (line 211). #[derive(Debug, Clone, PartialEq, Eq)] pub struct AvailableAgentType { @@ -57,7 +57,7 @@ pub struct LeadPromptParams<'a> { /// /// Placeholders replaced (mirrors AionUi `leadPrompt.ts`): /// - `${teammateList}` — bullet list of teammates or an empty-team fallback sentence -/// - `${availableTypesSection}` — `## Available Agent Types for Spawning` section, or `""` +/// - `${availableTypesSection}` — `## Available Generic Backends for Spawning` section, or `""` /// - `${availableAssistantsSection}` — `## Available Preset Assistants for Spawning` section, or `""` /// - `${workspaceSection}` — `## Team Workspace` section, or `""` /// - `${presetFormattingStepRule}` — phase1 emits `""` (presets not surfaced in phase1) @@ -112,14 +112,16 @@ fn render_available_types_section(agent_types: &[AvailableAgentType]) -> String if agent_types.is_empty() { return String::new(); } - let mut out = String::from("\n\n## Available Agent Types for Spawning\n"); + let mut out = String::from("\n\n## Available Generic Backends for Spawning\n"); for (idx, t) in agent_types.iter().enumerate() { if idx > 0 { out.push('\n'); } let _ = write!(out, "- `{}` — {}", t.agent_type, t.display_name); } - out.push_str("\n\nUse `team_list_models` to query available models for each agent type before spawning."); + out.push_str( + "\n\nUse `team_list_models` to query available models for each backend before spawning.", + ); out } @@ -163,7 +165,7 @@ fn render_available_assistants_section(assistants: &[AvailableAssistant]) -> Str candidate to see its full description, skills, and example tasks, then choose the best \ fit.\n\ 3. If no preset matches the task, fall back to a generic CLI agent from the \ - \"Available Agent Types\" section.\n\n\ + \"Available Generic Backends\" section.\n\n\ Pass the preset's ID as `assistant_id` to `team_spawn_agent`. The `agent_type` is \ derived from the preset's backend and does not need to be specified.", ); @@ -312,10 +314,10 @@ mod tests { display_name: "code generation specialist".into(), }, ]); - assert!(got.starts_with("\n\n## Available Agent Types for Spawning\n")); + assert!(got.starts_with("\n\n## Available Generic Backends for Spawning\n")); assert!(got.contains("- `claude` — general-purpose AI assistant")); assert!(got.contains("- `codex` — code generation specialist")); - assert!(got.contains("Use `team_list_models`")); + assert!(got.contains("Use `team_list_models` to query available models for each backend before spawning.")); } #[test] @@ -422,7 +424,7 @@ mod tests { assert!(!out.contains("${"), "unsubstituted placeholder:\n{out}"); assert!(out.contains("## Your Teammates")); assert!(out.contains("- Worker1 (claude, status: unknown)")); - assert!(out.contains("## Available Agent Types for Spawning")); + assert!(out.contains("## Available Generic Backends for Spawning")); assert!(!out.contains("## Available Preset Assistants for Spawning")); assert!(out.contains("## Team Workspace")); assert!(out.contains("STEP:END")); diff --git a/crates/aionui-team/src/prompts/prompt_templates/lead.txt b/crates/aionui-team/src/prompts/prompt_templates/lead.txt index d93d4dd02..9e1b3f12b 100644 --- a/crates/aionui-team/src/prompts/prompt_templates/lead.txt +++ b/crates/aionui-team/src/prompts/prompt_templates/lead.txt @@ -8,7 +8,7 @@ results.${workspaceSection} ## Conversation Style - If the user greets you, starts a new chat, or asks what you can do without giving a concrete task yet, reply warmly and naturally - In that opening reply, briefly introduce yourself as the team leader and invite the user to share their goal -- Do NOT mention teammate proposals, recommended agent types, or confirmation workflow until there is a concrete task that may actually need more teammates +- Do NOT mention teammate proposals, recommended assistants/backends, or confirmation workflow until there is a concrete task that may actually need more teammates ## Your Teammates ${teammateList}${availableTypesSection}${availableAssistantsSection} @@ -24,11 +24,11 @@ Use `team_members` and `team_task_list` to check current team state. ## Workflow 1. Receive user request 2. Analyze the request and decide whether the current team is enough -3. If additional teammates would help, FIRST call `team_list_models` to check available models for each agent type you plan to use +3. If additional teammates would help, FIRST call `team_list_models` to check available models for each assistant backend or fallback backend you plan to use 4. Then reply in text with a staffing proposal 5. Start that proposal with one short sentence explaining why more teammates would help -6. Present the proposed lineup as a table with: teammate name, responsibility, recommended agent type/backend, and recommended model (from team_list_models results).${presetFormattingStepRule} -7. Ask whether the user wants to create those teammates as proposed or change any names, responsibilities, or agent types +6. Present the proposed lineup as a table with: teammate name, responsibility, recommended assistant or backend, and recommended model (from team_list_models results).${presetFormattingStepRule} +7. Ask whether the user wants to create those teammates as proposed or change any names, responsibilities, or assistant choices 8. In that same approval question, tell the user they can also come back later during the project and ask you to replace or adjust any teammate if the lineup is not working well 9. End your turn after the proposal. Do NOT call team_spawn_agent in that same turn - Exception: If the message contains a [SYSTEM NOTE] indicating the user has already confirmed the lineup, skip the proposal step and proceed directly to spawning all listed teammates @@ -40,7 +40,7 @@ Use `team_members` and `team_task_list` to check current team state. 15. Synthesize results and respond to the user ## Model Selection Guidelines -- Before spawning teammates, use `team_list_models` to check available models for that agent type +- Before spawning teammates, use `team_list_models` to check available models for that assistant backend or fallback backend - You MUST use the exact model ID strings returned by team_list_models — never shorten or invent model names - For complex reasoning tasks: prefer the strongest model available for that backend - For routine tasks: prefer faster/cheaper models from the list @@ -83,11 +83,11 @@ When the user explicitly asks to dismiss/fire/shut down teammates: - Do NOT call team_spawn_agent immediately just because the task sounds broad, hard, or multi-step - When you think new teammates are needed, first explain why in one short sentence, then recommend the teammate lineup - ${presetFormattingImportantRule} -- Ask whether the user wants to create the proposed teammates as-is or change any names, responsibilities, or agent types +- Ask whether the user wants to create the proposed teammates as-is or change any names, responsibilities, or assistant choices - In that approval question, also remind the user that they can later ask you to replace, remove, or retune any teammate if the lineup is not working for them - End your turn after the proposal and wait for the user's reply - Wait for explicit confirmation before using team_spawn_agent (exception: if a [SYSTEM NOTE] in the message indicates the user already confirmed, spawn immediately) -- If the user asks to change a proposed teammate's role, name, or agent type, revise the proposal in text and wait for confirmation again +- If the user asks to change a proposed teammate's role, name, or assistant choice, revise the proposal in text and wait for confirmation again - If the user later says they are unhappy with an existing teammate, adjust the lineup by renaming, replacing, or shutting down teammates as needed based on their request - If the user explicitly says to create a specific teammate immediately, you may use team_spawn_agent without an extra confirmation turn - When the user says "add", "create", "spawn", or "hire" a teammate but the lineup is not finalized yet, respond with the proposal first instead of spawning immediately @@ -97,4 +97,4 @@ When the user explicitly asks to dismiss/fire/shut down teammates: - If a teammate fails, reassign or adjust the plan - Refer to teammates by their name (e.g., "researcher", "developer") - Do NOT duplicate work that teammates are already doing -- Be patient with idle teammates — idle means waiting for input, not done \ No newline at end of file +- Be patient with idle teammates — idle means waiting for input, not done diff --git a/crates/aionui-team/src/prompts/team_guide.rs b/crates/aionui-team/src/prompts/team_guide.rs index 690fd9683..915455647 100644 --- a/crates/aionui-team/src/prompts/team_guide.rs +++ b/crates/aionui-team/src/prompts/team_guide.rs @@ -46,15 +46,15 @@ You can create a multi-agent Team for the user. If case 2 applies, ask at most once whether the user wants to bring in a Team. Keep it brief and optional. If the user says no, ignores it, or prefers solo help, continue solo and do not mention Team again. ### How to proceed when Team is requested or approved (STRICT — follow every step, do NOT skip) -1. FIRST call `aion_list_models` to check available models for each agent type you plan to use. +1. FIRST call `aion_list_models` to check available models for each assistant backend or fallback backend you plan to use. 2. Explain in one sentence why the Team setup helps this task. -3. Present a team configuration table: role name, responsibility, agent type, and recommended model (from aion_list_models results) for each member. Example format: - | Role | Responsibility | Type | Model | +3. Present a team configuration table: role name, responsibility, assistant or backend, and recommended model (from aion_list_models results) for each member. Example format: + | Role | Responsibility | Assistant / Backend | Model | | Leader | Coordinate and review | {leader_cell} | (default) | | Developer | Implement features | {agent_type} | (model from list) | | Tester | Write and run tests | {agent_type} | (model from list) | 4. **Output the table as a normal text message and END YOUR TURN.** Do NOT call `aion_create_team` or any other tool (including ask_user) in this turn. Wait for the user to reply in their next message with explicit confirmation (e.g. \"ok\", \"go ahead\", \"确认\") before proceeding. -5. After user confirms → call `aion_create_team`. The summary MUST include both the goal and the confirmed team configuration. (The system automatically sets the correct agent type — you do NOT need to pass agentType.) +5. After user confirms → call `aion_create_team`. The summary MUST include both the goal and the confirmed team configuration. (The system automatically derives the correct backend from a chosen assistant — you do NOT need to pass agentType when using assistant identities.) 6. After `aion_create_team` returns → you ARE now the team Leader. The system navigates to the team page automatically. **Immediately** use `team_spawn_agent` to create each teammate from the confirmed configuration table. Then use `team_send_message` to assign initial tasks to each spawned teammate. Do NOT end your turn until all teammates are spawned and tasked. 7. User declines or wants changes → adjust or proceed solo. Do not mention Team again unless the user asks. @@ -119,15 +119,15 @@ Handle the task yourself in the current chat by default. Do NOT proactively reco If case 2 applies, ask at most once whether the user wants to bring in a Team. Keep it brief and optional. If the user says no, ignores it, or prefers solo help, continue solo and do not mention Team again.\n\ \n\ ### How to proceed when Team is requested or approved (STRICT — follow every step, do NOT skip)\n\ -1. FIRST call `aion_list_models` to check available models for each agent type you plan to use.\n\ +1. FIRST call `aion_list_models` to check available models for each assistant backend or fallback backend you plan to use.\n\ 2. Explain in one sentence why the Team setup helps this task.\n\ -3. Present a team configuration table: role name, responsibility, agent type, and recommended model (from aion_list_models results) for each member. Example format:\n \ -| Role | Responsibility | Type | Model |\n \ +3. Present a team configuration table: role name, responsibility, assistant or backend, and recommended model (from aion_list_models results) for each member. Example format:\n \ +| Role | Responsibility | Assistant / Backend | Model |\n \ | Leader | Coordinate and review | claude | (default) |\n \ | Developer | Implement features | claude | (model from list) |\n \ | Tester | Write and run tests | claude | (model from list) |\n\ 4. **Output the table as a normal text message and END YOUR TURN.** Do NOT call `aion_create_team` or any other tool (including ask_user) in this turn. Wait for the user to reply in their next message with explicit confirmation (e.g. \"ok\", \"go ahead\", \"确认\") before proceeding.\n\ -5. After user confirms → call `aion_create_team`. The summary MUST include both the goal and the confirmed team configuration. (The system automatically sets the correct agent type — you do NOT need to pass agentType.)\n\ +5. After user confirms → call `aion_create_team`. The summary MUST include both the goal and the confirmed team configuration. (The system automatically derives the correct backend from a chosen assistant — you do NOT need to pass agentType when using assistant identities.)\n\ 6. After `aion_create_team` returns → you ARE now the team Leader. The system navigates to the team page automatically. **Immediately** use `team_spawn_agent` to create each teammate from the confirmed configuration table. Then use `team_send_message` to assign initial tasks to each spawned teammate. Do NOT end your turn until all teammates are spawned and tasked.\n\ 7. User declines or wants changes → adjust or proceed solo. Do not mention Team again unless the user asks.\n\ \n\ From ab711d4b50ce7c608d7ed31a9d52b39170c2cba5 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 08:12:10 +0800 Subject: [PATCH 030/135] refactor(channel): split assistant setting read and write contracts --- crates/aionui-api-types/src/channel.rs | 21 +++- crates/aionui-api-types/src/lib.rs | 10 +- crates/aionui-channel/src/channel_settings.rs | 100 +++++++++++++----- crates/aionui-channel/src/routes.rs | 4 +- 4 files changed, 97 insertions(+), 38 deletions(-) diff --git a/crates/aionui-api-types/src/channel.rs b/crates/aionui-api-types/src/channel.rs index 70883f900..7c66cdefb 100644 --- a/crates/aionui-api-types/src/channel.rs +++ b/crates/aionui-api-types/src/channel.rs @@ -87,12 +87,23 @@ pub struct SyncChannelSettingsRequest { pub platform: String, } -/// Assistant binding for a channel platform. +/// Assistant binding request for a channel platform. /// -/// Stored as backend-owned business data and used to resolve which assistant -/// should handle new inbound channel conversations for a given platform. +/// New writes must use assistant identity only. Legacy backend / agent fields +/// remain readable in responses for historical settings migration. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ChannelAssistantSetting { +pub struct ChannelAssistantSettingRequest { + pub assistant_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +/// Assistant binding returned by channel settings endpoints. +/// +/// Responses remain backward-compatible while historical channel settings are +/// still being read from assistant/backend mixed records. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChannelAssistantSettingResponse { #[serde(skip_serializing_if = "Option::is_none")] pub assistant_id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -117,7 +128,7 @@ pub struct ChannelDefaultModelSetting { pub struct ChannelPlatformSettingsResponse { pub platform: String, #[serde(skip_serializing_if = "Option::is_none")] - pub assistant: Option, + pub assistant: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_model: Option, } diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index fa34c45d4..72df0d382 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -64,11 +64,11 @@ pub use auth::{ WebuiChangeUsernameResponse, WebuiGenerateQrTokenResponse, WebuiResetPasswordResponse, WsTokenResponse, }; pub use channel::{ - ApprovePairingRequest, BridgeResponse, ChannelAssistantSetting, ChannelDefaultModelSetting, - ChannelPlatformSettingsResponse, ChannelSessionResponse, ChannelUserResponse, DisablePluginRequest, - EnablePluginRequest, PairingRequestResponse, PairingRequestedPayload, PluginStatusChangedPayload, - PluginStatusResponse, RejectPairingRequest, RevokeUserRequest, SyncChannelSettingsRequest, TestPluginExtraConfig, - TestPluginRequest, TestPluginResponse, UserAuthorizedPayload, + ApprovePairingRequest, BridgeResponse, ChannelAssistantSettingRequest, ChannelAssistantSettingResponse, + ChannelDefaultModelSetting, ChannelPlatformSettingsResponse, ChannelSessionResponse, ChannelUserResponse, + DisablePluginRequest, EnablePluginRequest, PairingRequestResponse, PairingRequestedPayload, + PluginStatusChangedPayload, PluginStatusResponse, RejectPairingRequest, RevokeUserRequest, + SyncChannelSettingsRequest, TestPluginExtraConfig, TestPluginRequest, TestPluginResponse, UserAuthorizedPayload, }; pub use confirmation::{ApprovalCheckQuery, ApprovalCheckResponse, ConfirmRequest, ConfirmationListResponse}; pub use connection_test::TestBedrockConnectionRequest; diff --git a/crates/aionui-channel/src/channel_settings.rs b/crates/aionui-channel/src/channel_settings.rs index 4ac3de819..214c9e9b9 100644 --- a/crates/aionui-channel/src/channel_settings.rs +++ b/crates/aionui-channel/src/channel_settings.rs @@ -1,6 +1,9 @@ use std::sync::Arc; -use aionui_api_types::{ChannelAssistantSetting, ChannelDefaultModelSetting, ChannelPlatformSettingsResponse}; +use aionui_api_types::{ + ChannelAssistantSettingRequest, ChannelAssistantSettingResponse, ChannelDefaultModelSetting, + ChannelPlatformSettingsResponse, +}; use aionui_common::ProviderWithModel; use aionui_db::{IAssistantDefinitionRepository, IAssistantOverlayRepository, IClientPreferenceRepository}; use tracing::debug; @@ -175,7 +178,7 @@ impl ChannelSettingsService { pub async fn get_assistant_setting( &self, platform: PluginType, - ) -> Result, ChannelError> { + ) -> Result, ChannelError> { let key = agent_key(platform); let prefs = self.pref_repo.get_by_keys(&[&key]).await?; @@ -183,14 +186,13 @@ impl ChannelSettingsService { return Ok(None); }; - Ok(parse_channel_assistant_setting(&pref.value) - .map(|assistant| normalize_channel_assistant_setting_for_write(&assistant))) + Ok(parse_channel_assistant_setting(&pref.value).map(normalize_channel_assistant_setting_for_response)) } pub async fn set_assistant_setting( &self, platform: PluginType, - assistant: &ChannelAssistantSetting, + assistant: &ChannelAssistantSettingRequest, ) -> Result<(), ChannelError> { let normalized = normalize_channel_assistant_setting_for_write(assistant); let payload = serde_json::to_string(&normalized).map_err(ChannelError::Json)?; @@ -251,11 +253,11 @@ fn default_agent_config() -> ResolvedAgentConfig { } } -fn parse_channel_assistant_setting(value: &str) -> Option { +fn parse_channel_assistant_setting(value: &str) -> Option { let parsed: serde_json::Value = serde_json::from_str(value).ok()?; if let Some(raw) = parsed.as_str() { - return Some(ChannelAssistantSetting { + return Some(ChannelAssistantSettingResponse { assistant_id: None, custom_agent_id: None, backend: Some(raw.to_owned()), @@ -264,7 +266,7 @@ fn parse_channel_assistant_setting(value: &str) -> Option Option ChannelAssistantSetting { +fn normalize_channel_assistant_setting_for_write( + assistant: &ChannelAssistantSettingRequest, +) -> ChannelAssistantSettingResponse { + ChannelAssistantSettingResponse { + assistant_id: Some(assistant.assistant_id.trim().to_owned()), + custom_agent_id: None, + backend: None, + agent_type: None, + name: assistant.name.clone(), + } +} + +fn normalize_channel_assistant_setting_for_response( + assistant: ChannelAssistantSettingResponse, +) -> ChannelAssistantSettingResponse { let assistant_id = assistant .assistant_id .as_deref() @@ -287,12 +303,16 @@ fn normalize_channel_assistant_setting_for_write(assistant: &ChannelAssistantSet .map(ToOwned::to_owned) }); - ChannelAssistantSetting { - assistant_id, - custom_agent_id: None, - backend: None, - agent_type: None, - name: assistant.name.clone(), + if assistant_id.is_some() { + ChannelAssistantSettingResponse { + assistant_id, + custom_agent_id: None, + backend: None, + agent_type: None, + name: assistant.name, + } + } else { + assistant } } @@ -722,11 +742,8 @@ mod tests { svc.set_assistant_setting( PluginType::Telegram, - &ChannelAssistantSetting { - assistant_id: Some("assistant-1".into()), - custom_agent_id: Some("legacy-custom".into()), - backend: Some("claude".into()), - agent_type: Some("acp".into()), + &ChannelAssistantSettingRequest { + assistant_id: "assistant-1".into(), name: Some("Claude".into()), }, ) @@ -744,17 +761,14 @@ mod tests { } #[tokio::test] - async fn set_assistant_setting_promotes_legacy_custom_agent_id() { + async fn set_assistant_setting_trims_assistant_id_before_persisting() { let repo = Arc::new(MockPrefRepo::new()); let svc = ChannelSettingsService::new(repo.clone()); svc.set_assistant_setting( PluginType::Lark, - &ChannelAssistantSetting { - assistant_id: None, - custom_agent_id: Some("legacy-custom".into()), - backend: Some("codex".into()), - agent_type: Some("acp".into()), + &ChannelAssistantSettingRequest { + assistant_id: " legacy-custom ".into(), name: Some("Codex".into()), }, ) @@ -771,6 +785,40 @@ mod tests { assert!(payload.get("agent_type").is_none()); } + #[tokio::test] + async fn get_assistant_setting_promotes_legacy_custom_agent_id_in_response() { + let repo = Arc::new(MockPrefRepo::with_data(vec![( + "assistant.telegram.agent", + r#"{"custom_agent_id":"legacy-custom","name":"Codex"}"#, + )])); + let svc = ChannelSettingsService::new(repo); + + let setting = svc.get_assistant_setting(PluginType::Telegram).await.unwrap().unwrap(); + + assert_eq!(setting.assistant_id.as_deref(), Some("legacy-custom")); + assert!(setting.custom_agent_id.is_none()); + assert!(setting.backend.is_none()); + assert!(setting.agent_type.is_none()); + assert_eq!(setting.name.as_deref(), Some("Codex")); + } + + #[tokio::test] + async fn get_assistant_setting_preserves_backend_only_legacy_response() { + let repo = Arc::new(MockPrefRepo::with_data(vec![( + "assistant.lark.agent", + r#"{"backend":"codex","name":"Codex"}"#, + )])); + let svc = ChannelSettingsService::new(repo); + + let setting = svc.get_assistant_setting(PluginType::Lark).await.unwrap().unwrap(); + + assert!(setting.assistant_id.is_none()); + assert!(setting.custom_agent_id.is_none()); + assert_eq!(setting.backend.as_deref(), Some("codex")); + assert!(setting.agent_type.is_none()); + assert_eq!(setting.name.as_deref(), Some("Codex")); + } + // ── resolved_model_to_provider ──────────────────────────────────── #[test] diff --git a/crates/aionui-channel/src/routes.rs b/crates/aionui-channel/src/routes.rs index 370b62d10..51b17ca48 100644 --- a/crates/aionui-channel/src/routes.rs +++ b/crates/aionui-channel/src/routes.rs @@ -9,7 +9,7 @@ use axum::routing::{get, post, put}; use tracing::warn; use aionui_api_types::{ - ApiResponse, ApprovePairingRequest, BridgeResponse, ChannelAssistantSetting, ChannelDefaultModelSetting, + ApiResponse, ApprovePairingRequest, BridgeResponse, ChannelAssistantSettingRequest, ChannelDefaultModelSetting, ChannelPlatformSettingsResponse, ChannelSessionResponse, ChannelUserResponse, DisablePluginRequest, EnablePluginRequest, PairingRequestResponse, PluginStatusResponse, RejectPairingRequest, RevokeUserRequest, SyncChannelSettingsRequest, TestPluginRequest, TestPluginResponse, @@ -581,7 +581,7 @@ async fn get_channel_settings( async fn set_channel_assistant_setting( State(state): State, Path(platform): Path, - body: Result, JsonRejection>, + body: Result, JsonRejection>, ) -> Result>, ApiError> { let platform = PluginType::from_str_opt(&platform) .ok_or_else(|| ApiError::BadRequest(format!("Invalid platform: {}", platform)))?; From c673afa8354fe2ad9e68d2c4b84f80eedb9ea8c7 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 08:17:39 +0800 Subject: [PATCH 031/135] refactor(channel): prefer assistant-first channel settings --- crates/aionui-channel/src/message_service.rs | 5 ++--- crates/aionui-channel/tests/message_service_integration.rs | 4 +++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/aionui-channel/src/message_service.rs b/crates/aionui-channel/src/message_service.rs index 32fe5c649..17c19e0ea 100644 --- a/crates/aionui-channel/src/message_service.rs +++ b/crates/aionui-channel/src/message_service.rs @@ -125,16 +125,15 @@ impl ChannelMessageService { platform: PluginType, ) -> Result { let source = platform_to_source(platform); - let agent_type = parse_agent_type(&session.agent_type)?; - let agent_config = self.settings.get_agent_config(platform).await?; let assistant_setting = self.settings.get_assistant_setting(platform).await?; let model_config = self.settings.get_model_config(platform).await?; + let agent_type = parse_agent_type(&agent_config.agent_type)?; let model = resolved_model_to_provider(model_config.as_ref()); let mut extra = Self::build_channel_extra(agent_config.backend.as_deref()); let name = channel_conversation_name( platform, - &session.agent_type, + &agent_config.agent_type, agent_config.backend.as_deref(), session.chat_id.as_deref(), ); diff --git a/crates/aionui-channel/tests/message_service_integration.rs b/crates/aionui-channel/tests/message_service_integration.rs index c03907d07..ab09695ba 100644 --- a/crates/aionui-channel/tests/message_service_integration.rs +++ b/crates/aionui-channel/tests/message_service_integration.rs @@ -323,7 +323,7 @@ async fn send_to_agent_persists_assistant_snapshot_for_channel_bound_assistant() let session = AssistantSessionRow { id: "session-assisted".to_owned(), user_id: "channel-user-1".to_owned(), - agent_type: "acp".to_owned(), + agent_type: "aionrs".to_owned(), conversation_id: None, workspace: None, chat_id: Some("7088048016".to_owned()), @@ -345,6 +345,8 @@ async fn send_to_agent_persists_assistant_snapshot_for_channel_bound_assistant() "channel-created conversation should persist an assistant snapshot when the platform is bound to an assistant" ); let snapshot = snapshot.unwrap(); + let conversation = conversation_repo.get(&result.conversation_id).await.unwrap().unwrap(); + assert_eq!(conversation.r#type, AgentType::Acp.serde_name()); assert_eq!(snapshot.assistant_key, "bare-claude"); assert_eq!(snapshot.agent_backend, "claude"); } From a885ef26578cc8b59a576817f41b654a782d7dce Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 08:21:57 +0800 Subject: [PATCH 032/135] test(channel): expect assistant-only binding writes --- crates/aionui-app/tests/channel_e2e.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/crates/aionui-app/tests/channel_e2e.rs b/crates/aionui-app/tests/channel_e2e.rs index e11bf2f86..1e6ba456e 100644 --- a/crates/aionui-app/tests/channel_e2e.rs +++ b/crates/aionui-app/tests/channel_e2e.rs @@ -339,9 +339,6 @@ async fn put_channel_assistant_setting_persists_binding() { "/api/channel/settings/telegram/assistant", json!({ "assistant_id": "bare-claude", - "custom_agent_id": "bare-claude", - "backend": "claude", - "agent_type": "acp", "name": "Claude", }), &token, @@ -359,9 +356,6 @@ async fn put_channel_assistant_setting_persists_binding() { json["data"]["assistant"], json!({ "assistant_id": "bare-claude", - "custom_agent_id": "bare-claude", - "backend": "claude", - "agent_type": "acp", "name": "Claude", }) ); From 5568f5de9d2cb5a9c345b143703ad12b37281ab8 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 08:36:35 +0800 Subject: [PATCH 033/135] refactor(team): make backend optional for assistant-led requests --- crates/aionui-api-types/src/team.rs | 14 ++--- crates/aionui-team/src/guide/server.rs | 2 +- crates/aionui-team/src/provisioning.rs | 12 ++-- .../tests/session_service_integration.rs | 60 +++++++++---------- 4 files changed, 44 insertions(+), 44 deletions(-) diff --git a/crates/aionui-api-types/src/team.rs b/crates/aionui-api-types/src/team.rs index b69db45da..9925135a8 100644 --- a/crates/aionui-api-types/src/team.rs +++ b/crates/aionui-api-types/src/team.rs @@ -19,7 +19,7 @@ pub struct TeamAgentInput { pub name: String, pub role: String, #[serde(default)] - pub backend: String, + pub backend: Option, pub model: String, #[serde(default)] pub assistant_id: Option, @@ -63,7 +63,7 @@ pub struct AddAgentRequest { pub name: String, pub role: String, #[serde(default)] - pub backend: String, + pub backend: Option, pub model: String, #[serde(default)] pub assistant_id: Option, @@ -456,7 +456,7 @@ mod tests { assert_eq!(req.agents.len(), 2); assert_eq!(req.agents[0].name, "Lead"); assert_eq!(req.agents[0].role, "lead"); - assert_eq!(req.agents[0].backend, "acp"); + assert_eq!(req.agents[0].backend.as_deref(), Some("acp")); assert_eq!(req.agents[0].model, "claude"); assert_eq!(req.agents[0].assistant_id.as_deref(), Some("assistant-x")); assert_eq!(req.agents[1].name, "Worker"); @@ -498,7 +498,7 @@ mod tests { "assistant_id": "assistant-x" }); let input: TeamAgentInput = serde_json::from_value(raw).unwrap(); - assert_eq!(input.backend, ""); + assert!(input.backend.is_none()); assert_eq!(input.assistant_id.as_deref(), Some("assistant-x")); } @@ -550,7 +550,7 @@ mod tests { let req: AddAgentRequest = serde_json::from_value(raw).unwrap(); assert_eq!(req.name, "Helper"); assert_eq!(req.role, "teammate"); - assert_eq!(req.backend, "acp"); + assert_eq!(req.backend.as_deref(), Some("acp")); assert_eq!(req.model, "claude"); assert!(req.custom_agent_id.is_none()); } @@ -592,7 +592,7 @@ mod tests { fn deserialize_add_agent_request_missing_backend() { let raw = json!({ "name": "X", "role": "teammate", "model": "claude" }); let req = serde_json::from_value::(raw).unwrap(); - assert_eq!(req.backend, ""); + assert!(req.backend.is_none()); } #[test] @@ -604,7 +604,7 @@ mod tests { "assistant_id": "assistant-1" }); let req = serde_json::from_value::(raw).unwrap(); - assert_eq!(req.backend, ""); + assert!(req.backend.is_none()); assert_eq!(req.assistant_id.as_deref(), Some("assistant-1")); } diff --git a/crates/aionui-team/src/guide/server.rs b/crates/aionui-team/src/guide/server.rs index 84e0c81f0..330e7616b 100644 --- a/crates/aionui-team/src/guide/server.rs +++ b/crates/aionui-team/src/guide/server.rs @@ -245,7 +245,7 @@ async fn exec_create_team( agents: vec![TeamAgentInput { name: "Leader".to_owned(), role: "leader".to_owned(), - backend: backend.clone(), + backend: Some(backend.clone()), model: model.clone(), assistant_id: None, custom_agent_id: None, diff --git a/crates/aionui-team/src/provisioning.rs b/crates/aionui-team/src/provisioning.rs index 79ca03795..1b37cecb5 100644 --- a/crates/aionui-team/src/provisioning.rs +++ b/crates/aionui-team/src/provisioning.rs @@ -146,7 +146,7 @@ impl TeamAgentProvisioner { leader_input.custom_agent_id.as_deref(), ); let leader_backend = self - .resolve_requested_backend(leader_input.backend.as_str(), leader_assistant_id.as_deref()) + .resolve_requested_backend(leader_input.backend.as_deref(), leader_assistant_id.as_deref()) .await?; let leader_conversation = self .create_or_adopt_conversation( @@ -195,7 +195,7 @@ impl TeamAgentProvisioner { let assistant_id = Self::effective_assistant_id(input.assistant_id.as_deref(), input.custom_agent_id.as_deref()); let backend = self - .resolve_requested_backend(input.backend.as_str(), assistant_id.as_deref()) + .resolve_requested_backend(input.backend.as_deref(), assistant_id.as_deref()) .await?; let conversation = self .create_or_adopt_conversation( @@ -254,7 +254,7 @@ impl TeamAgentProvisioner { let workspace = self.workspace_resolver().resolve_for_new_agent(row, team).await?; let assistant_id = Self::effective_assistant_id(req.assistant_id.as_deref(), req.custom_agent_id.as_deref()); let backend = self - .resolve_requested_backend(req.backend.as_str(), assistant_id.as_deref()) + .resolve_requested_backend(req.backend.as_deref(), assistant_id.as_deref()) .await?; let agent = self .provision_new_agent(NewAgentProvisioning { @@ -275,11 +275,11 @@ impl TeamAgentProvisioner { async fn resolve_requested_backend( &self, - requested_backend: &str, + requested_backend: Option<&str>, assistant_id: Option<&str>, ) -> Result { - let requested_backend = requested_backend.trim(); - if !requested_backend.is_empty() { + let requested_backend = requested_backend.map(str::trim).filter(|value| !value.is_empty()); + if let Some(requested_backend) = requested_backend { return Ok(requested_backend.to_owned()); } diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index 3bca82164..198f2f3c8 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -1367,7 +1367,7 @@ fn two_agent_input() -> Vec { TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -1376,7 +1376,7 @@ fn two_agent_input() -> Vec { TeamAgentInput { name: "Worker".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -1513,7 +1513,7 @@ async fn tc_create_team_uses_assistant_id_icon_lookup() { agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: Some("2d23ff1c".into()), @@ -1550,7 +1550,7 @@ async fn tc_create_team_carries_assistant_identity_into_lead_conversation_extra( agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "claude".into(), + backend: Some("claude".into()), model: "claude".into(), assistant_id: Some("2d23ff1c".into()), custom_agent_id: Some("2d23ff1c".into()), @@ -1638,7 +1638,7 @@ async fn tc_create_team_derives_backend_from_assistant_when_backend_missing() { agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: String::new(), + backend: Some(String::new()), model: "gpt-5".into(), assistant_id: Some("assistant-lead".into()), custom_agent_id: None, @@ -1677,7 +1677,7 @@ async fn ta_add_agent_uses_model_fallback_for_acp_backend() { agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -1696,7 +1696,7 @@ async fn ta_add_agent_uses_model_fallback_for_acp_backend() { AddAgentRequest { name: "Coder".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "codex".into(), assistant_id: None, custom_agent_id: None, @@ -1773,7 +1773,7 @@ async fn ta_add_agent_derives_backend_from_assistant_when_backend_missing() { agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "claude".into(), + backend: Some("claude".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -1792,7 +1792,7 @@ async fn ta_add_agent_derives_backend_from_assistant_when_backend_missing() { AddAgentRequest { name: "Worker".into(), role: "teammate".into(), - backend: String::new(), + backend: Some(String::new()), model: "gpt-5".into(), assistant_id: Some("assistant-worker".into()), custom_agent_id: None, @@ -1816,7 +1816,7 @@ async fn tc2_create_single_agent_team() { agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -1844,7 +1844,7 @@ async fn tc4_first_agent_is_lead() { TeamAgentInput { name: "A".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -1853,7 +1853,7 @@ async fn tc4_first_agent_is_lead() { TeamAgentInput { name: "B".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -1985,7 +1985,7 @@ async fn tl_list_teams_includes_pending_confirmation_counts_without_rebuilding_t agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -2158,7 +2158,7 @@ async fn aa1_add_agent_to_team() { agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -2177,7 +2177,7 @@ async fn aa1_add_agent_to_team() { AddAgentRequest { name: "Worker".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -2210,7 +2210,7 @@ async fn aa_add_agent_inherits_team_workspace() { agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -2229,7 +2229,7 @@ async fn aa_add_agent_inherits_team_workspace() { AddAgentRequest { name: "Worker".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -2258,7 +2258,7 @@ async fn add_agent_backfills_empty_team_workspace_from_leader_workspace() { agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -2283,7 +2283,7 @@ async fn add_agent_backfills_empty_team_workspace_from_leader_workspace() { AddAgentRequest { name: "Worker".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -2314,7 +2314,7 @@ async fn add_agent_uses_team_temp_workspace_when_team_and_leader_workspaces_are_ agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -2341,7 +2341,7 @@ async fn add_agent_uses_team_temp_workspace_when_team_and_leader_workspaces_are_ AddAgentRequest { name: "Worker".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -2377,7 +2377,7 @@ async fn add_agent_does_not_create_teammate_when_workspace_writeback_fails() { agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -2400,7 +2400,7 @@ async fn add_agent_does_not_create_teammate_when_workspace_writeback_fails() { AddAgentRequest { name: "Worker".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -2429,7 +2429,7 @@ async fn add_agent_continues_when_team_temp_leader_patch_fails() { agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -2459,7 +2459,7 @@ async fn add_agent_continues_when_team_temp_leader_patch_fails() { AddAgentRequest { name: "Worker".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -2523,7 +2523,7 @@ async fn provisioning_writes_typed_team_binding_for_create_and_add_agent() { AddAgentRequest { name: "Extra".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -2559,7 +2559,7 @@ async fn aa4_add_agent_to_nonexistent_team() { AddAgentRequest { name: "X".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -3217,7 +3217,7 @@ async fn w4_d23_concurrent_add_agent_preserves_every_insertion() { agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -3239,7 +3239,7 @@ async fn w4_d23_concurrent_add_agent_preserves_every_insertion() { AddAgentRequest { name: "WorkerA".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, @@ -3258,7 +3258,7 @@ async fn w4_d23_concurrent_add_agent_preserves_every_insertion() { AddAgentRequest { name: "WorkerB".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, From 93c73710172763b878c6b343fee2a48b69ba0d93 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 10:15:48 +0800 Subject: [PATCH 034/135] refactor(channel): drop direct agent switching --- crates/aionui-app/src/router/state.rs | 1 - crates/aionui-channel/src/action.rs | 103 ++++-------------- .../aionui-channel/tests/orchestrator_test.rs | 2 +- .../tests/session_action_integration.rs | 2 +- 4 files changed, 25 insertions(+), 83 deletions(-) diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index 183517d9f..7ac9c81c1 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -500,7 +500,6 @@ pub async fn build_channel_state( Arc::clone(&pairing_service), Arc::clone(&session_manager), Arc::clone(&channel_settings), - "acp", )); let conv_repo: Arc = Arc::new( diff --git a/crates/aionui-channel/src/action.rs b/crates/aionui-channel/src/action.rs index 40e469412..0d9d54c0c 100644 --- a/crates/aionui-channel/src/action.rs +++ b/crates/aionui-channel/src/action.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::sync::Arc; use tracing::{debug, info, warn}; @@ -41,7 +40,6 @@ pub struct ActionExecutor { pairing: Arc, session_mgr: Arc, settings: Arc, - default_agent_type: String, } impl ActionExecutor { @@ -49,13 +47,11 @@ impl ActionExecutor { pairing: Arc, session_mgr: Arc, settings: Arc, - default_agent_type: &str, ) -> Self { Self { pairing, session_mgr, settings, - default_agent_type: default_agent_type.to_owned(), } } @@ -285,10 +281,9 @@ impl ActionExecutor { "help.features" => Ok(ActionResponse { text: Some( "Features:\n\ - • AI chat with multiple backends\n\ + • AI chat through your configured assistant\n\ • Tool execution with auto-approval\n\ - • Session isolation per chat\n\ - • Agent switching" + • Session isolation per chat" .into(), ), parse_mode: None, @@ -339,52 +334,6 @@ impl ActionExecutor { toast: None, edit_message_id: None, }), - "agent.show" => Ok(ActionResponse { - text: Some("Available agents:".into()), - parse_mode: None, - buttons: Some(vec![vec![ - ActionButton { - label: "Gemini".into(), - action: "agent.select".into(), - params: Some(HashMap::from([("agentType".into(), "gemini".into())])), - }, - ActionButton { - label: "ACP".into(), - action: "agent.select".into(), - params: Some(HashMap::from([("agentType".into(), "acp".into())])), - }, - ]]), - keyboard: None, - behavior: ActionBehavior::Send, - toast: None, - edit_message_id: None, - }), - "agent.select" => { - let agent_type = action - .params - .as_ref() - .and_then(|p| p.get("agentType")) - .map(|s| s.as_str()) - .unwrap_or(&self.default_agent_type); - - // Persist the agent_type change to the session - let chat_id = &action.context.chat_id; - let session = self - .session_mgr - .get_or_create_session(internal_user_id, chat_id, agent_type, None) - .await?; - self.session_mgr.update_agent_type(&session.id, agent_type).await?; - - Ok(ActionResponse { - text: Some(format!("Agent switched to: {agent_type}")), - parse_mode: None, - buttons: None, - keyboard: None, - behavior: ActionBehavior::Send, - toast: Some(format!("Switched to {agent_type}")), - edit_message_id: None, - }) - } other => { warn!(action = %other, "unknown system action"); Ok(build_unknown_action_response(other)) @@ -521,11 +470,6 @@ fn build_help_response() -> ActionResponse { params: None, }, ], - vec![ActionButton { - label: "Switch Agent".into(), - action: "agent.show".into(), - params: None, - }], ]), keyboard: None, behavior: ActionBehavior::Send, @@ -557,6 +501,7 @@ mod tests { }; use aionui_db::{DbError, IChannelRepository, IClientPreferenceRepository, UpdatePluginStatusParams}; use aionui_realtime::EventBroadcaster; + use std::collections::HashMap; use std::sync::Mutex; // ── Mock EventBroadcaster ────────────────────────────────────────── @@ -751,7 +696,7 @@ mod tests { let session_mgr = Arc::new(SessionManager::new(repo.clone())); let pref_repo: Arc = Arc::new(MockPrefRepo); let settings = Arc::new(ChannelSettingsService::new(pref_repo)); - let executor = ActionExecutor::new(pairing, session_mgr, settings, "gemini"); + let executor = ActionExecutor::new(pairing, session_mgr, settings); (executor, repo) } @@ -1077,46 +1022,44 @@ mod tests { assert!(resp.buttons.is_some()); let buttons = resp.buttons.unwrap(); assert!(buttons.len() >= 2); // at least 2 rows + assert!( + !buttons.iter().flatten().any(|button| button.action == "agent.show"), + "help menu must not expose direct agent selection" + ); } _ => panic!("Expected Action result"), } } #[tokio::test] - async fn agent_select_with_params() { + async fn agent_show_is_treated_as_unknown_action() { let (executor, repo) = setup(); repo.add_authorized_user("tg_42", "telegram"); - let params = HashMap::from([("agentType".into(), "acp".into())]); let msg = make_action_message( "tg_42", "chat_1", - "agent.select", + "agent.show", ActionCategory::System, PluginType::Telegram, - Some(params), + None, ); let result = executor.handle_incoming_message(&msg).await.unwrap(); match result { MessageResult::Action(resp) => { let text = resp.text.unwrap(); - assert!(text.contains("acp")); - assert!(resp.toast.is_some()); + assert!(text.contains("Unknown action")); + assert!(text.contains("agent.show")); } _ => panic!("Expected Action result"), } } #[tokio::test] - async fn agent_select_persists_agent_type() { + async fn agent_select_is_treated_as_unknown_action() { let (executor, repo) = setup(); repo.add_authorized_user("tg_42", "telegram"); - // First: send a text to create a session (defaults to "aionrs") - let text_msg = make_text_message("tg_42", "chat_1", "Hello", PluginType::Telegram); - executor.handle_incoming_message(&text_msg).await.unwrap(); - - // Then: switch agent to "acp" let params = HashMap::from([("agentType".into(), "acp".into())]); let select_msg = make_action_message( "tg_42", @@ -1126,15 +1069,15 @@ mod tests { PluginType::Telegram, Some(params), ); - executor.handle_incoming_message(&select_msg).await.unwrap(); - - // Verify session's agent_type was updated in the repo - let sessions = repo.sessions.lock().unwrap(); - let session = sessions - .iter() - .find(|s| s.user_id == "user_tg_42" && s.chat_id.as_deref() == Some("chat_1")) - .expect("session should exist"); - assert_eq!(session.agent_type, "acp"); + let result = executor.handle_incoming_message(&select_msg).await.unwrap(); + match result { + MessageResult::Action(resp) => { + let text = resp.text.unwrap(); + assert!(text.contains("Unknown action")); + assert!(text.contains("agent.select")); + } + _ => panic!("Expected Action result"), + } } // ── Chat action tests ────────────────────────────────────────────── diff --git a/crates/aionui-channel/tests/orchestrator_test.rs b/crates/aionui-channel/tests/orchestrator_test.rs index 11f36fcac..3b7c710aa 100644 --- a/crates/aionui-channel/tests/orchestrator_test.rs +++ b/crates/aionui-channel/tests/orchestrator_test.rs @@ -45,7 +45,7 @@ async fn unauthorized_user_gets_pairing_response() { let pairing = Arc::new(PairingService::new(repo.clone(), bus)); let session_mgr = Arc::new(SessionManager::new(repo)); - let executor = Arc::new(ActionExecutor::new(pairing, Arc::clone(&session_mgr), settings, "acp")); + let executor = Arc::new(ActionExecutor::new(pairing, Arc::clone(&session_mgr), settings)); let msg = make_text_message("unknown_user", "chat_1", "hello"); let result = executor.handle_incoming_message(&msg).await.unwrap(); diff --git a/crates/aionui-channel/tests/session_action_integration.rs b/crates/aionui-channel/tests/session_action_integration.rs index c102ee6bf..97e4843a1 100644 --- a/crates/aionui-channel/tests/session_action_integration.rs +++ b/crates/aionui-channel/tests/session_action_integration.rs @@ -57,7 +57,7 @@ async fn setup() -> ( let pref_repo: Arc = Arc::new(aionui_db::SqliteClientPreferenceRepository::new(db.pool().clone())); let settings = Arc::new(ChannelSettingsService::new(pref_repo)); - let executor = ActionExecutor::new(pairing_arc, session_mgr_arc, settings, "gemini"); + let executor = ActionExecutor::new(pairing_arc, session_mgr_arc, settings); // Keep db alive std::mem::forget(db); From 258b139563e64ed07670e1009aaf2d96c30f6c34 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 10:31:43 +0800 Subject: [PATCH 035/135] refactor(team): make prompts and tools assistant-first --- crates/aionui-team/src/mcp/tools.rs | 19 ++++- crates/aionui-team/src/prompts/lead.rs | 78 ++++++++----------- crates/aionui-team/src/prompts/mod.rs | 11 ++- .../src/prompts/prompt_templates/lead.txt | 8 +- crates/aionui-team/src/prompts/team_guide.rs | 28 ++++--- crates/aionui-team/src/test_utils.rs | 2 +- 6 files changed, 72 insertions(+), 74 deletions(-) diff --git a/crates/aionui-team/src/mcp/tools.rs b/crates/aionui-team/src/mcp/tools.rs index 8248de611..cd2a731f2 100644 --- a/crates/aionui-team/src/mcp/tools.rs +++ b/crates/aionui-team/src/mcp/tools.rs @@ -21,13 +21,13 @@ Use this only when one of the following is true: Before calling this tool in the normal planning flow: - Start with one short sentence explaining why additional teammates would help - Tell the user which teammate(s) you recommend -- Present the proposal as a table with: name, responsibility, recommended assistant or backend, and recommended model -- Include each teammate's responsibility, recommended assistant or backend, and model +- Present the proposal as a table with: name, responsibility, recommended assistant, and recommended model +- Include each teammate's responsibility, recommended assistant, and model - Ask whether to create them as proposed or change any names, responsibilities, or assistant choices - In that approval question, remind the user that they can later ask you to replace or adjust any teammate if the lineup is not working well - Do NOT call this tool in that same turn; wait for explicit approval in a later user message -When a preset assistant is a good fit, prefer passing assistant_id. Use agent_type/backend only as a compatibility fallback when no preset assistant fits the requested role. +When an assistant is a good fit, prefer passing assistant_id. The legacy agent_type/backend fields remain available only for compatibility with older flows. When calling this tool, provide the model parameter if a specific model was recommended and approved. @@ -747,6 +747,19 @@ mod tests { assert!(backend_desc.contains("Prefer assistant_id first")); } + #[test] + fn team_spawn_agent_description_uses_assistant_first_staffing_language() { + let desc = all_tool_descriptors() + .into_iter() + .find(|d| d.name == "team_spawn_agent") + .unwrap(); + assert!( + desc.description + .contains("recommended assistant, and recommended model") + ); + assert!(!desc.description.contains("recommended assistant or backend")); + } + // ---- D4 handlers return non-error payloads ---- #[test] diff --git a/crates/aionui-team/src/prompts/lead.rs b/crates/aionui-team/src/prompts/lead.rs index 236f1f701..7f8d4a203 100644 --- a/crates/aionui-team/src/prompts/lead.rs +++ b/crates/aionui-team/src/prompts/lead.rs @@ -24,8 +24,11 @@ const PLACEHOLDER_WORKSPACE_SECTION: &str = "${workspaceSection}"; const PLACEHOLDER_PRESET_FORMATTING_STEP_RULE: &str = "${presetFormattingStepRule}"; const PLACEHOLDER_PRESET_FORMATTING_IMPORTANT_RULE: &str = "${presetFormattingImportantRule}"; -/// A generic backend fallback that the leader may spawn when no preset assistant fits. -/// Phase1 shape per interface-contracts §5 (line 211). +/// Legacy backend fallback metadata. +/// +/// Phase 2 no longer surfaces generic backends in user-facing staffing prompts, but +/// the struct remains for compatibility with older call sites and tests that may +/// still construct the params object. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AvailableAgentType { pub agent_type: String, @@ -57,8 +60,8 @@ pub struct LeadPromptParams<'a> { /// /// Placeholders replaced (mirrors AionUi `leadPrompt.ts`): /// - `${teammateList}` — bullet list of teammates or an empty-team fallback sentence -/// - `${availableTypesSection}` — `## Available Generic Backends for Spawning` section, or `""` -/// - `${availableAssistantsSection}` — `## Available Preset Assistants for Spawning` section, or `""` +/// - `${availableTypesSection}` — phase 2 emits `""` (generic backends are not surfaced) +/// - `${availableAssistantsSection}` — `## Available Assistants for Spawning` section, or `""` /// - `${workspaceSection}` — `## Team Workspace` section, or `""` /// - `${presetFormattingStepRule}` — phase1 emits `""` (presets not surfaced in phase1) /// - `${presetFormattingImportantRule}` — phase1 emits `""` (presets not surfaced in phase1) @@ -109,31 +112,19 @@ fn render_teammate_list(teammates: &[TeamAgent], renamed_agents: &HashMap String { - if agent_types.is_empty() { - return String::new(); - } - let mut out = String::from("\n\n## Available Generic Backends for Spawning\n"); - for (idx, t) in agent_types.iter().enumerate() { - if idx > 0 { - out.push('\n'); - } - let _ = write!(out, "- `{}` — {}", t.agent_type, t.display_name); - } - out.push_str( - "\n\nUse `team_list_models` to query available models for each backend before spawning.", - ); - out + let _ = agent_types; + String::new() } fn render_available_assistants_section(assistants: &[AvailableAssistant]) -> String { if assistants.is_empty() { return String::new(); } - let mut out = String::from("\n\n## Available Preset Assistants for Spawning\n"); + let mut out = String::from("\n\n## Available Assistants for Spawning\n"); out.push_str( - "These are user-configured assistants with pre-loaded rules and skills for specific \ - domains (writing, research, PPT building, etc.). When a task matches a preset's \ - specialty, prefer spawning the preset over a generic CLI agent — you get its domain \ + "These are available assistants with pre-loaded rules and skills for specific \ + domains (writing, research, PPT building, etc.). When a task matches an assistant's \ + specialty, prefer spawning that assistant — you get its domain \ expertise automatically.\n\n", ); for (idx, a) in assistants.iter().enumerate() { @@ -150,24 +141,20 @@ fn render_available_assistants_section(assistants: &[AvailableAssistant]) -> Str } else { format!("\n skills: {}", a.skills.join(", ")) }; - let _ = write!( - out, - "- `{}` ({}, backend: {}){}{}", - a.assistant_id, a.name, a.backend, desc, skills, - ); + let _ = write!(out, "- `{}` ({}){}{}", a.assistant_id, a.name, desc, skills,); } out.push_str( - "\n\n### How to pick a preset\n\ + "\n\n### How to pick an assistant\n\ 1. Scan the one-line descriptions and skills above. If one clearly matches the user's \ domain (e.g. \"quarterly Word report\" → `word-creator`), spawn it directly with \ `team_spawn_agent`.\n\ - 2. If two or more presets seem relevant, call `team_describe_assistant` on each \ + 2. If two or more assistants seem relevant, call `team_describe_assistant` on each \ candidate to see its full description, skills, and example tasks, then choose the best \ fit.\n\ - 3. If no preset matches the task, fall back to a generic CLI agent from the \ - \"Available Generic Backends\" section.\n\n\ - Pass the preset's ID as `assistant_id` to `team_spawn_agent`. The `agent_type` is \ - derived from the preset's backend and does not need to be specified.", + 3. If no assistant is an obvious fit, choose the assistant whose domain and backing \ + capabilities best match the work.\n\n\ + Pass the assistant's ID as `assistant_id` to `team_spawn_agent`. The runtime backend \ + is derived automatically and does not need to be specified.", ); out } @@ -303,7 +290,7 @@ mod tests { } #[test] - fn available_types_section_includes_backtick_ids_and_model_query_hint() { + fn available_types_section_is_hidden_for_assistant_only_team_prompts() { let got = render_available_types_section(&[ AvailableAgentType { agent_type: "claude".into(), @@ -314,10 +301,7 @@ mod tests { display_name: "code generation specialist".into(), }, ]); - assert!(got.starts_with("\n\n## Available Generic Backends for Spawning\n")); - assert!(got.contains("- `claude` — general-purpose AI assistant")); - assert!(got.contains("- `codex` — code generation specialist")); - assert!(got.contains("Use `team_list_models` to query available models for each backend before spawning.")); + assert_eq!(got, ""); } #[test] @@ -334,11 +318,13 @@ mod tests { description: "Drafts Word documents".into(), skills: vec!["docx".into(), "formatting".into()], }]); - assert!(got.contains("## Available Preset Assistants for Spawning")); - assert!(got.contains("- `word-creator` (Word Creator, backend: claude) — Drafts Word documents")); + assert!(got.contains("## Available Assistants for Spawning")); + assert!(got.contains("- `word-creator` (Word Creator) — Drafts Word documents")); assert!(got.contains("skills: docx, formatting")); - assert!(got.contains("### How to pick a preset")); - assert!(got.contains("Pass the preset's ID as `assistant_id`")); + assert!(got.contains("### How to pick an assistant")); + assert!(got.contains("Pass the assistant's ID as `assistant_id`")); + assert!(!got.contains("backend: claude")); + assert!(!got.contains("Generic Backends")); assert!(!got.contains("custom_agent_id")); } @@ -399,10 +385,7 @@ mod tests { let params = LeadPromptParams { team_name: "Beta", teammates: std::slice::from_ref(&t), - available_agent_types: &[AvailableAgentType { - agent_type: "claude".into(), - display_name: "general-purpose AI assistant".into(), - }], + available_agent_types: &[], available_assistants: &[], renamed_agents: &renamed, team_workspace: Some("/tmp/team-ws"), @@ -424,7 +407,8 @@ mod tests { assert!(!out.contains("${"), "unsubstituted placeholder:\n{out}"); assert!(out.contains("## Your Teammates")); assert!(out.contains("- Worker1 (claude, status: unknown)")); - assert!(out.contains("## Available Generic Backends for Spawning")); + assert!(!out.contains("## Available Generic Backends for Spawning")); + assert!(!out.contains("assistant or backend")); assert!(!out.contains("## Available Preset Assistants for Spawning")); assert!(out.contains("## Team Workspace")); assert!(out.contains("STEP:END")); diff --git a/crates/aionui-team/src/prompts/mod.rs b/crates/aionui-team/src/prompts/mod.rs index 55145fe34..d71b91a38 100644 --- a/crates/aionui-team/src/prompts/mod.rs +++ b/crates/aionui-team/src/prompts/mod.rs @@ -266,16 +266,17 @@ mod tests { let assistants = default_assistants(); let prompt = build_lead_prompt("Alpha", &[], &assistants); - assert!(prompt.contains("## Available Preset Assistants for Spawning")); - assert!(prompt.contains("- `word-creator` (Word Creator, backend: claude) — Drafts Word documents")); + assert!(prompt.contains("## Available Assistants for Spawning")); + assert!(prompt.contains("- `word-creator` (Word Creator) — Drafts Word documents")); assert!(prompt.contains("skills: docx, formatting")); - assert!(prompt.contains("Pass the preset's ID as `assistant_id`")); + assert!(prompt.contains("Pass the assistant's ID as `assistant_id`")); + assert!(!prompt.contains("backend: claude")); } #[test] fn lead_prompt_omits_available_assistants_section_when_empty() { let prompt = build_lead_prompt("Alpha", &[], &[]); - assert!(!prompt.contains("## Available Preset Assistants for Spawning")); + assert!(!prompt.contains("## Available Assistants for Spawning")); } #[test] @@ -295,6 +296,8 @@ mod tests { !prompt.contains("${"), "unsubstituted template placeholder leaked:\n{prompt}" ); + assert!(!prompt.contains("assistant or backend")); + assert!(!prompt.contains("Available Generic Backends")); } // -- Teammate prompt ------------------------------------------------------ diff --git a/crates/aionui-team/src/prompts/prompt_templates/lead.txt b/crates/aionui-team/src/prompts/prompt_templates/lead.txt index 9e1b3f12b..39e6e3d1b 100644 --- a/crates/aionui-team/src/prompts/prompt_templates/lead.txt +++ b/crates/aionui-team/src/prompts/prompt_templates/lead.txt @@ -8,7 +8,7 @@ results.${workspaceSection} ## Conversation Style - If the user greets you, starts a new chat, or asks what you can do without giving a concrete task yet, reply warmly and naturally - In that opening reply, briefly introduce yourself as the team leader and invite the user to share their goal -- Do NOT mention teammate proposals, recommended assistants/backends, or confirmation workflow until there is a concrete task that may actually need more teammates +- Do NOT mention teammate proposals, recommended assistants, or confirmation workflow until there is a concrete task that may actually need more teammates ## Your Teammates ${teammateList}${availableTypesSection}${availableAssistantsSection} @@ -24,10 +24,10 @@ Use `team_members` and `team_task_list` to check current team state. ## Workflow 1. Receive user request 2. Analyze the request and decide whether the current team is enough -3. If additional teammates would help, FIRST call `team_list_models` to check available models for each assistant backend or fallback backend you plan to use +3. If additional teammates would help, FIRST call `team_list_models` to check available models for each assistant backend you plan to use 4. Then reply in text with a staffing proposal 5. Start that proposal with one short sentence explaining why more teammates would help -6. Present the proposed lineup as a table with: teammate name, responsibility, recommended assistant or backend, and recommended model (from team_list_models results).${presetFormattingStepRule} +6. Present the proposed lineup as a table with: teammate name, responsibility, recommended assistant, and recommended model (from team_list_models results).${presetFormattingStepRule} 7. Ask whether the user wants to create those teammates as proposed or change any names, responsibilities, or assistant choices 8. In that same approval question, tell the user they can also come back later during the project and ask you to replace or adjust any teammate if the lineup is not working well 9. End your turn after the proposal. Do NOT call team_spawn_agent in that same turn @@ -40,7 +40,7 @@ Use `team_members` and `team_task_list` to check current team state. 15. Synthesize results and respond to the user ## Model Selection Guidelines -- Before spawning teammates, use `team_list_models` to check available models for that assistant backend or fallback backend +- Before spawning teammates, use `team_list_models` to check available models for that assistant backend - You MUST use the exact model ID strings returned by team_list_models — never shorten or invent model names - For complex reasoning tasks: prefer the strongest model available for that backend - For routine tasks: prefer faster/cheaper models from the list diff --git a/crates/aionui-team/src/prompts/team_guide.rs b/crates/aionui-team/src/prompts/team_guide.rs index 915455647..ace96a2d5 100644 --- a/crates/aionui-team/src/prompts/team_guide.rs +++ b/crates/aionui-team/src/prompts/team_guide.rs @@ -24,7 +24,7 @@ const STAY_SOLO_CRITERIA: &str = "\ const SOLO_DEFAULT_RULE: &str = "Handle the task yourself in the current chat by default. Do NOT proactively recommend Team just because the work spans multiple files, takes multiple rounds, or would benefit from specialization."; -/// Full Team Guide prompt template with `{leader_cell}` / `{agent_type}` +/// Full Team Guide prompt template with `{leader_cell}` /// placeholders. Exported for cross-crate snapshot tests and the Wave 5 /// capability injector; prefer [`build_team_guide_prompt`] for runtime use. pub const TEAM_GUIDE_PROMPT_TEMPLATE: &str = "## Team Mode @@ -46,13 +46,13 @@ You can create a multi-agent Team for the user. If case 2 applies, ask at most once whether the user wants to bring in a Team. Keep it brief and optional. If the user says no, ignores it, or prefers solo help, continue solo and do not mention Team again. ### How to proceed when Team is requested or approved (STRICT — follow every step, do NOT skip) -1. FIRST call `aion_list_models` to check available models for each assistant backend or fallback backend you plan to use. +1. FIRST call `aion_list_models` to check available models for each assistant backend you plan to use. 2. Explain in one sentence why the Team setup helps this task. -3. Present a team configuration table: role name, responsibility, assistant or backend, and recommended model (from aion_list_models results) for each member. Example format: - | Role | Responsibility | Assistant / Backend | Model | +3. Present a team configuration table: role name, responsibility, recommended assistant, and recommended model (from aion_list_models results) for each member. Example format: + | Role | Responsibility | Assistant | Model | | Leader | Coordinate and review | {leader_cell} | (default) | - | Developer | Implement features | {agent_type} | (model from list) | - | Tester | Write and run tests | {agent_type} | (model from list) | + | Developer | Implement features | Suitable assistant | (model from list) | + | Tester | Write and run tests | Suitable assistant | (model from list) | 4. **Output the table as a normal text message and END YOUR TURN.** Do NOT call `aion_create_team` or any other tool (including ask_user) in this turn. Wait for the user to reply in their next message with explicit confirmation (e.g. \"ok\", \"go ahead\", \"确认\") before proceeding. 5. After user confirms → call `aion_create_team`. The summary MUST include both the goal and the confirmed team configuration. (The system automatically derives the correct backend from a chosen assistant — you do NOT need to pass agentType when using assistant identities.) 6. After `aion_create_team` returns → you ARE now the team Leader. The system navigates to the team page automatically. **Immediately** use `team_spawn_agent` to create each teammate from the confirmed configuration table. Then use `team_send_message` to assign initial tasks to each spawned teammate. Do NOT end your turn until all teammates are spawned and tasked. @@ -83,7 +83,6 @@ pub fn build_team_guide_prompt(backend: &str, leader_label: Option<&str>) -> Str .replace("{extreme_complexity_criteria}", EXTREME_COMPLEXITY_CRITERIA) .replace("{stay_solo_criteria}", STAY_SOLO_CRITERIA) .replace("{leader_cell}", &leader_cell) - .replace("{agent_type}", agent_type) } #[cfg(test)] @@ -119,13 +118,13 @@ Handle the task yourself in the current chat by default. Do NOT proactively reco If case 2 applies, ask at most once whether the user wants to bring in a Team. Keep it brief and optional. If the user says no, ignores it, or prefers solo help, continue solo and do not mention Team again.\n\ \n\ ### How to proceed when Team is requested or approved (STRICT — follow every step, do NOT skip)\n\ -1. FIRST call `aion_list_models` to check available models for each assistant backend or fallback backend you plan to use.\n\ +1. FIRST call `aion_list_models` to check available models for each assistant backend you plan to use.\n\ 2. Explain in one sentence why the Team setup helps this task.\n\ -3. Present a team configuration table: role name, responsibility, assistant or backend, and recommended model (from aion_list_models results) for each member. Example format:\n \ -| Role | Responsibility | Assistant / Backend | Model |\n \ +3. Present a team configuration table: role name, responsibility, recommended assistant, and recommended model (from aion_list_models results) for each member. Example format:\n \ +| Role | Responsibility | Assistant | Model |\n \ | Leader | Coordinate and review | claude | (default) |\n \ -| Developer | Implement features | claude | (model from list) |\n \ -| Tester | Write and run tests | claude | (model from list) |\n\ +| Developer | Implement features | Suitable assistant | (model from list) |\n \ +| Tester | Write and run tests | Suitable assistant | (model from list) |\n\ 4. **Output the table as a normal text message and END YOUR TURN.** Do NOT call `aion_create_team` or any other tool (including ask_user) in this turn. Wait for the user to reply in their next message with explicit confirmation (e.g. \"ok\", \"go ahead\", \"确认\") before proceeding.\n\ 5. After user confirms → call `aion_create_team`. The summary MUST include both the goal and the confirmed team configuration. (The system automatically derives the correct backend from a chosen assistant — you do NOT need to pass agentType when using assistant identities.)\n\ 6. After `aion_create_team` returns → you ARE now the team Leader. The system navigates to the team page automatically. **Immediately** use `team_spawn_agent` to create each teammate from the confirmed configuration table. Then use `team_send_message` to assign initial tasks to each spawned teammate. Do NOT end your turn until all teammates are spawned and tasked.\n\ @@ -140,10 +139,9 @@ Before team creation: use **only** `aion_create_team` and `aion_list_models`. Af fn team_guide_prompt_with_preset_leader_label() { let prompt = build_team_guide_prompt("gemini", Some("Word Creator")); assert!(prompt.contains("| Leader | Coordinate and review | Word Creator (gemini) | (default) |")); - assert!(prompt.contains("| Developer | Implement features | gemini | (model from list) |")); - assert!(prompt.contains("| Tester | Write and run tests | gemini | (model from list) |")); + assert!(prompt.contains("| Developer | Implement features | Suitable assistant | (model from list) |")); + assert!(prompt.contains("| Tester | Write and run tests | Suitable assistant | (model from list) |")); assert!(!prompt.contains("{leader_cell}")); - assert!(!prompt.contains("{agent_type}")); } #[test] diff --git a/crates/aionui-team/src/test_utils.rs b/crates/aionui-team/src/test_utils.rs index 9b19e7e43..740f9bca5 100644 --- a/crates/aionui-team/src/test_utils.rs +++ b/crates/aionui-team/src/test_utils.rs @@ -931,7 +931,7 @@ pub(crate) mod workspace_harness { agents: vec![aionui_api_types::TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), assistant_id: None, custom_agent_id: None, From 940f19900b9115eec474ee2c1575d8e423bd197c Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 10:35:09 +0800 Subject: [PATCH 036/135] refactor(team): remove preset assistant wording --- crates/aionui-team/src/mcp/tools.rs | 31 +++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/aionui-team/src/mcp/tools.rs b/crates/aionui-team/src/mcp/tools.rs index cd2a731f2..4f2db876a 100644 --- a/crates/aionui-team/src/mcp/tools.rs +++ b/crates/aionui-team/src/mcp/tools.rs @@ -45,13 +45,13 @@ Pass agent_type to query a specific backend, or omit it to see all backends."; /// Description for `team_describe_assistant` — verbatim from team-prompts.md §5.2. pub const TEAM_DESCRIBE_ASSISTANT_DESCRIPTION: &str = - "Get detailed information about a preset assistant before spawning it as a teammate. + "Get detailed information about an assistant before spawning it as a teammate. -Returns the preset's full description, enabled skills, and example tasks so you can -judge whether it fits the user's request. Use this when two or more presets look +Returns the assistant's full description, enabled skills, and example tasks so you can +judge whether it fits the user's request. Use this when two or more assistants look relevant from the one-line catalog in your system prompt. -Only works on preset assistants listed in \"Available Preset Assistants for Spawning\". +Only works on assistants listed in \"Available Assistants for Spawning\". After confirming a match, call team_spawn_agent with the same assistant_id."; // --------------------------------------------------------------------------- @@ -86,9 +86,9 @@ pub fn all_tool_descriptors() -> Vec { "type": "object", "properties": { "name": { "type": "string", "description": "Agent display name" }, - "agent_type": { "type": "string", "description": "Fallback backend to use (e.g. \"claude\", \"codex\", \"codebuddy\", \"gemini\") when no preset assistant fits. Query team_list_models first to see available options." }, + "agent_type": { "type": "string", "description": "Fallback backend to use (e.g. \"claude\", \"codex\", \"codebuddy\", \"gemini\") when no assistant fits. Query team_list_models first to see available options." }, "model": { "type": "string", "description": "Specific model ID to use (e.g. \"claude-sonnet-4\"). Must be valid for the chosen assistant backend or fallback agent_type. Query team_list_models to see available models." }, - "assistant_id": { "type": "string", "description": "Preferred preset assistant ID to spawn (from the Available Preset Assistants catalog). When set, agent_type is derived from the preset's backend." }, + "assistant_id": { "type": "string", "description": "Preferred assistant ID to spawn (from the Available Assistants catalog). When set, agent_type is derived from the assistant's backend." }, "backend": { "type": "string", "description": "Legacy alias for agent_type. Prefer assistant_id first, then agent_type." }, "role": { "type": "string", "description": "Agent role (default: 'teammate')" } }, @@ -171,7 +171,7 @@ pub fn all_tool_descriptors() -> Vec { input_schema: json!({ "type": "object", "properties": { - "assistant_id": { "type": "string", "description": "The preset assistant ID from the \"Available Preset Assistants\" catalog (e.g., \"word-creator\")." }, + "assistant_id": { "type": "string", "description": "The assistant ID from the available assistants catalog (e.g., \"word-creator\")." }, "locale": { "type": "string", "description": "Locale like \"zh-CN\" or \"en-US\". Defaults to the user's current UI language when omitted." } }, "required": ["assistant_id"] @@ -742,7 +742,7 @@ mod tests { let assistant_desc = props["assistant_id"]["description"].as_str().unwrap(); let agent_type_desc = props["agent_type"]["description"].as_str().unwrap(); let backend_desc = props["backend"]["description"].as_str().unwrap(); - assert!(assistant_desc.starts_with("Preferred preset assistant ID")); + assert!(assistant_desc.starts_with("Preferred assistant ID")); assert!(agent_type_desc.starts_with("Fallback backend to use")); assert!(backend_desc.contains("Prefer assistant_id first")); } @@ -760,6 +760,21 @@ mod tests { assert!(!desc.description.contains("recommended assistant or backend")); } + #[test] + fn team_describe_assistant_description_uses_assistant_only_wording() { + let desc = all_tool_descriptors() + .into_iter() + .find(|d| d.name == "team_describe_assistant") + .unwrap(); + let props = desc.input_schema["properties"].as_object().unwrap(); + let assistant_desc = props["assistant_id"]["description"].as_str().unwrap(); + assert!(desc.description.contains("Get detailed information about an assistant")); + assert!(!desc.description.contains("preset assistant")); + assert!(!desc.description.contains("Available Preset Assistants")); + assert!(assistant_desc.starts_with("The assistant ID from the available assistants catalog")); + assert!(!assistant_desc.contains("preset assistant ID")); + } + // ---- D4 handlers return non-error payloads ---- #[test] From b04148466a58cd5fcce6b609aafb08b53bf4f37d Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 10:38:45 +0800 Subject: [PATCH 037/135] refactor(team): stop writing preset assistant ids --- crates/aionui-team/src/provisioning.rs | 1 - crates/aionui-team/tests/session_service_integration.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/aionui-team/src/provisioning.rs b/crates/aionui-team/src/provisioning.rs index 1b37cecb5..f1ac8126e 100644 --- a/crates/aionui-team/src/provisioning.rs +++ b/crates/aionui-team/src/provisioning.rs @@ -587,7 +587,6 @@ impl TeamAgentProvisioner { } if let Some(assistant_id) = assistant_id { extra["assistant_id"] = serde_json::Value::String(assistant_id.to_owned()); - extra["preset_assistant_id"] = serde_json::Value::String(assistant_id.to_owned()); } if let Some(workspace) = workspace { inherit_team_workspace(&mut extra, workspace); diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index 198f2f3c8..43d1aa81b 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -1570,7 +1570,7 @@ async fn tc_create_team_carries_assistant_identity_into_lead_conversation_extra( let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap(); assert_eq!(extra["assistant_id"], serde_json::json!("2d23ff1c")); - assert_eq!(extra["preset_assistant_id"], serde_json::json!("2d23ff1c")); + assert!(extra.get("preset_assistant_id").is_none()); } #[tokio::test] From af4500aacf9d46c7aa9371492c0de48041423e08 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 10:44:01 +0800 Subject: [PATCH 038/135] refactor(team): stop echoing legacy custom agent ids --- crates/aionui-api-types/src/team.rs | 4 ++-- crates/aionui-team/src/types.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/aionui-api-types/src/team.rs b/crates/aionui-api-types/src/team.rs index 9925135a8..5424e9e9b 100644 --- a/crates/aionui-api-types/src/team.rs +++ b/crates/aionui-api-types/src/team.rs @@ -665,7 +665,7 @@ mod tests { icon: Some("/api/assets/logos/ai-major/claude.svg".into()), model: "claude".into(), assistant_id: Some("assistant-x".into()), - custom_agent_id: Some("agent-x".into()), + custom_agent_id: None, status: Some("idle".into()), pending_confirmations: 2, }; @@ -678,7 +678,7 @@ mod tests { assert_eq!(json["icon"], "/api/assets/logos/ai-major/claude.svg"); assert_eq!(json["model"], "claude"); assert_eq!(json["assistant_id"], "assistant-x"); - assert_eq!(json["custom_agent_id"], "agent-x"); + assert!(json.get("custom_agent_id").is_none()); assert_eq!(json["status"], "idle"); assert_eq!(json["pending_confirmations"], 2); } diff --git a/crates/aionui-team/src/types.rs b/crates/aionui-team/src/types.rs index af1838284..85ef5a9e2 100644 --- a/crates/aionui-team/src/types.rs +++ b/crates/aionui-team/src/types.rs @@ -130,7 +130,7 @@ impl TeamAgent { icon, model: self.model.clone(), assistant_id: self.assistant_id.clone(), - custom_agent_id: self.assistant_id.clone(), + custom_agent_id: None, status: self.status.map(|s| s.to_string()), pending_confirmations: 0, } @@ -530,7 +530,7 @@ mod tests { assert!(resp.icon.is_none()); assert_eq!(resp.status.as_deref(), Some("working")); assert_eq!(resp.assistant_id.as_deref(), Some("custom-1")); - assert_eq!(resp.custom_agent_id.as_deref(), Some("custom-1")); + assert!(resp.custom_agent_id.is_none()); } #[test] From 5301e620fbc3f88bec34445325ae68afed63feeb Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 10:52:13 +0800 Subject: [PATCH 039/135] refactor(cron): canonicalize legacy assistant ids on write --- crates/aionui-cron/src/service.rs | 59 ++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index c967e7f78..dac9c8925 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -1260,6 +1260,19 @@ fn build_update_params(job: &CronJob, req: &UpdateCronJobRequest) -> UpdateCronJ } fn sanitize_agent_config_dto(mut config: aionui_api_types::CronAgentConfigDto) -> aionui_api_types::CronAgentConfigDto { + let has_assistant_id = config + .assistant_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); + if !has_assistant_id + && let Some(legacy_assistant_id) = config + .custom_agent_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + config.assistant_id = Some(legacy_assistant_id.to_owned()); + } let has_assistant_id = config .assistant_id .as_deref() @@ -1425,7 +1438,7 @@ mod tests { } #[test] - fn sanitize_agent_config_dto_keeps_legacy_ids_without_assistant_id() { + fn sanitize_agent_config_dto_promotes_legacy_custom_agent_id_without_assistant_id() { let config = aionui_api_types::CronAgentConfigDto { backend: "claude".into(), name: "Helper".into(), @@ -1442,9 +1455,10 @@ mod tests { let sanitized = sanitize_agent_config_dto(config); - assert_eq!(sanitized.custom_agent_id.as_deref(), Some("legacy-assistant")); - assert_eq!(sanitized.preset_agent_type.as_deref(), Some("claude")); - assert_eq!(sanitized.is_preset, Some(true)); + assert_eq!(sanitized.assistant_id.as_deref(), Some("legacy-assistant")); + assert!(sanitized.custom_agent_id.is_none()); + assert!(sanitized.preset_agent_type.is_none()); + assert!(sanitized.is_preset.is_none()); } // -- parse_execution_mode ------------------------------------------------- @@ -1595,6 +1609,43 @@ mod tests { assert!(config.is_preset.is_none()); } + #[test] + fn build_update_params_promotes_legacy_custom_agent_id_without_assistant_id() { + let job = sample_job(); + let req = UpdateCronJobRequest { + name: None, + description: None, + enabled: None, + schedule: None, + message: None, + execution_mode: None, + agent_config: Some(aionui_api_types::CronAgentConfigDto { + backend: "claude".into(), + name: "Helper".into(), + cli_path: None, + is_preset: Some(true), + assistant_id: None, + custom_agent_id: Some("legacy-assistant".into()), + preset_agent_type: Some("claude".into()), + mode: Some("default".into()), + model_id: Some("claude-sonnet-4".into()), + config_options: None, + workspace: None, + }), + conversation_title: None, + max_retries: None, + }; + + let params = build_update_params(&job, &req); + let config_json = params.agent_config.flatten().expect("agent config json"); + let config: CronAgentConfig = serde_json::from_str(&config_json).expect("parse cron config"); + + assert_eq!(config.assistant_id.as_deref(), Some("legacy-assistant")); + assert!(config.custom_agent_id.is_none()); + assert!(config.preset_agent_type.is_none()); + assert!(config.is_preset.is_none()); + } + #[test] fn preserves_existing_cron_timezone_when_update_omits_tz() { let existing = CronSchedule::Cron { From 46bf6f110dcc7f0b44cf37787ad2105b5ae6ea1c Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 11:07:53 +0800 Subject: [PATCH 040/135] refactor(cron): stop promoting legacy assistant ids --- crates/aionui-cron/src/service.rs | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index dac9c8925..526894f6d 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -1260,19 +1260,6 @@ fn build_update_params(job: &CronJob, req: &UpdateCronJobRequest) -> UpdateCronJ } fn sanitize_agent_config_dto(mut config: aionui_api_types::CronAgentConfigDto) -> aionui_api_types::CronAgentConfigDto { - let has_assistant_id = config - .assistant_id - .as_deref() - .is_some_and(|value| !value.trim().is_empty()); - if !has_assistant_id - && let Some(legacy_assistant_id) = config - .custom_agent_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - config.assistant_id = Some(legacy_assistant_id.to_owned()); - } let has_assistant_id = config .assistant_id .as_deref() @@ -1281,7 +1268,11 @@ fn sanitize_agent_config_dto(mut config: aionui_api_types::CronAgentConfigDto) - config.custom_agent_id = None; config.preset_agent_type = None; config.is_preset = None; + return config; } + config.custom_agent_id = None; + config.preset_agent_type = None; + config.is_preset = None; config } @@ -1438,7 +1429,7 @@ mod tests { } #[test] - fn sanitize_agent_config_dto_promotes_legacy_custom_agent_id_without_assistant_id() { + fn sanitize_agent_config_dto_drops_legacy_custom_agent_id_without_assistant_id() { let config = aionui_api_types::CronAgentConfigDto { backend: "claude".into(), name: "Helper".into(), @@ -1455,7 +1446,7 @@ mod tests { let sanitized = sanitize_agent_config_dto(config); - assert_eq!(sanitized.assistant_id.as_deref(), Some("legacy-assistant")); + assert!(sanitized.assistant_id.is_none()); assert!(sanitized.custom_agent_id.is_none()); assert!(sanitized.preset_agent_type.is_none()); assert!(sanitized.is_preset.is_none()); @@ -1610,7 +1601,7 @@ mod tests { } #[test] - fn build_update_params_promotes_legacy_custom_agent_id_without_assistant_id() { + fn build_update_params_drops_legacy_custom_agent_id_without_assistant_id() { let job = sample_job(); let req = UpdateCronJobRequest { name: None, @@ -1640,7 +1631,7 @@ mod tests { let config_json = params.agent_config.flatten().expect("agent config json"); let config: CronAgentConfig = serde_json::from_str(&config_json).expect("parse cron config"); - assert_eq!(config.assistant_id.as_deref(), Some("legacy-assistant")); + assert!(config.assistant_id.is_none()); assert!(config.custom_agent_id.is_none()); assert!(config.preset_agent_type.is_none()); assert!(config.is_preset.is_none()); From fb0a657d8eb13466a848b6d03d97ecd0b03f9172 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 11:15:14 +0800 Subject: [PATCH 041/135] refactor(cron): split cron write agent config dto --- crates/aionui-api-types/src/cron.rs | 22 ++++- crates/aionui-api-types/src/lib.rs | 6 +- crates/aionui-cron/src/service.rs | 130 ++++++++++------------------ 3 files changed, 71 insertions(+), 87 deletions(-) diff --git a/crates/aionui-api-types/src/cron.rs b/crates/aionui-api-types/src/cron.rs index 16c018d27..c1eff3196 100644 --- a/crates/aionui-api-types/src/cron.rs +++ b/crates/aionui-api-types/src/cron.rs @@ -60,6 +60,24 @@ pub struct CronAgentConfigDto { pub workspace: Option, } +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct CronAgentConfigWriteDto { + pub backend: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cli_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assistant_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_options: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, +} + // --------------------------------------------------------------------------- // C. CronJob response — nested structure matching API Spec // --------------------------------------------------------------------------- @@ -141,7 +159,7 @@ pub struct CreateCronJobRequest { #[serde(default)] pub execution_mode: Option, #[serde(default)] - pub agent_config: Option, + pub agent_config: Option, } #[derive(Debug, Clone, Deserialize)] @@ -159,7 +177,7 @@ pub struct UpdateCronJobRequest { #[serde(default)] pub execution_mode: Option, #[serde(default)] - pub agent_config: Option, + pub agent_config: Option, #[serde(default)] pub conversation_title: Option, #[serde(default)] diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index 72df0d382..db4c522ee 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -83,9 +83,9 @@ pub use conversation::{ UpdateConversationRequest, }; pub use cron::{ - CreateCronJobRequest, CronAgentConfigDto, CronJobExecutedEvent, CronJobMetadataDto, CronJobPayloadDto, - CronJobRemovedPayload, CronJobResponse, CronJobStateDto, CronJobTargetDto, CronScheduleDto, HasSkillResponse, - ListCronJobsQuery, RunNowResponse, SaveCronSkillRequest, UpdateCronJobRequest, + CreateCronJobRequest, CronAgentConfigDto, CronAgentConfigWriteDto, CronJobExecutedEvent, CronJobMetadataDto, + CronJobPayloadDto, CronJobRemovedPayload, CronJobResponse, CronJobStateDto, CronJobTargetDto, CronScheduleDto, + HasSkillResponse, ListCronJobsQuery, RunNowResponse, SaveCronSkillRequest, UpdateCronJobRequest, }; pub use custom_agent::{ CustomAgentAdvancedOverrides, CustomAgentUpsertRequest, DeleteCustomAgentResponse, SetEnabledRequest, diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index 526894f6d..6533d84f0 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -84,10 +84,10 @@ impl CronService { backend: c.backend, name: c.name, cli_path: c.cli_path, - is_preset: c.is_preset, + is_preset: None, assistant_id: c.assistant_id, - custom_agent_id: c.custom_agent_id, - preset_agent_type: c.preset_agent_type, + custom_agent_id: None, + preset_agent_type: None, mode: c.mode, model_id: c.model_id, config_options: c.config_options, @@ -170,10 +170,10 @@ impl CronService { backend: config_dto.backend.clone(), name: config_dto.name.clone(), cli_path: config_dto.cli_path.clone(), - is_preset: config_dto.is_preset, + is_preset: None, assistant_id: config_dto.assistant_id.clone(), - custom_agent_id: config_dto.custom_agent_id.clone(), - preset_agent_type: config_dto.preset_agent_type.clone(), + custom_agent_id: None, + preset_agent_type: None, mode: config_dto.mode.clone(), model_id: config_dto.model_id.clone(), config_options: config_dto.config_options.clone(), @@ -1035,7 +1035,7 @@ impl aionui_conversation::response_middleware::ICronService for CronService { fn build_agent_config_from_conversation( row: &aionui_db::models::ConversationRow, -) -> (String, Option) { +) -> (String, Option) { let extra = serde_json::from_str::(&row.extra).unwrap_or_else(|_| serde_json::json!({})); // Both interactive `send_message` and the cron executor parse // `conversation.model` via the same helper. Keeping the cron-side @@ -1062,14 +1062,7 @@ fn build_agent_config_from_conversation( }; let preset_assistant_id = get_string(&extra, &["preset_assistant_id", "presetAssistantId"]); - let assistant_id = get_string(&extra, &["assistant_id", "assistantId"]).or_else(|| preset_assistant_id.clone()); - let custom_agent_id = get_string(&extra, &["custom_agent_id", "customAgentId"]).or(preset_assistant_id.clone()); - let is_preset = preset_assistant_id.as_ref().map(|_| true); - let preset_agent_type = if preset_assistant_id.is_some() { - Some(backend.clone()) - } else { - None - }; + let assistant_id = get_string(&extra, &["assistant_id", "assistantId"]).or(preset_assistant_id); let agent_type_enum = serde_json::from_value::(serde_json::Value::String(row.r#type.clone())).ok(); // Backend is now the vendor label (e.g. "claude"); pass through as @@ -1078,7 +1071,7 @@ fn build_agent_config_from_conversation( .unwrap_or(AgentType::Acp) .full_auto_mode_id(Some(backend.as_str())) .to_owned(); - let agent_config = aionui_api_types::CronAgentConfigDto { + let agent_config = aionui_api_types::CronAgentConfigWriteDto { backend, name: get_string(&extra, &["agent_name", "agentName"]).unwrap_or_else(|| row.name.clone()), cli_path: get_string(&extra, &["cli_path", "cliPath"]).or_else(|| { @@ -1088,10 +1081,7 @@ fn build_agent_config_from_conversation( .and_then(|value| value.as_str()) .map(ToOwned::to_owned) }), - is_preset, assistant_id, - custom_agent_id, - preset_agent_type, mode: Some(full_auto_mode), model_id: get_string(&extra, &["current_model_id", "currentModelId"]).or_else(|| { model.and_then(|value| { @@ -1126,7 +1116,7 @@ fn get_string(extra: &serde_json::Value, keys: &[&str]) -> Option { /// Reject add/update requests that would produce an invalid aionrs job. fn validate_aionrs_agent_config( agent_type: &str, - agent_config: Option<&aionui_api_types::CronAgentConfigDto>, + agent_config: Option<&aionui_api_types::CronAgentConfigWriteDto>, ) -> Result<(), CronError> { if agent_type != "aionrs" { return Ok(()); @@ -1219,10 +1209,10 @@ fn build_update_params(job: &CronJob, req: &UpdateCronJobRequest) -> UpdateCronJ backend: c.backend.clone(), name: c.name.clone(), cli_path: c.cli_path.clone(), - is_preset: c.is_preset, + is_preset: None, assistant_id: c.assistant_id.clone(), - custom_agent_id: c.custom_agent_id.clone(), - preset_agent_type: c.preset_agent_type.clone(), + custom_agent_id: None, + preset_agent_type: None, mode: c.mode.clone(), model_id: c.model_id.clone(), config_options: c.config_options.clone(), @@ -1259,20 +1249,17 @@ fn build_update_params(job: &CronJob, req: &UpdateCronJobRequest) -> UpdateCronJ } } -fn sanitize_agent_config_dto(mut config: aionui_api_types::CronAgentConfigDto) -> aionui_api_types::CronAgentConfigDto { - let has_assistant_id = config - .assistant_id - .as_deref() - .is_some_and(|value| !value.trim().is_empty()); - if has_assistant_id { - config.custom_agent_id = None; - config.preset_agent_type = None; - config.is_preset = None; - return config; - } - config.custom_agent_id = None; - config.preset_agent_type = None; - config.is_preset = None; +fn sanitize_agent_config_dto( + mut config: aionui_api_types::CronAgentConfigWriteDto, +) -> aionui_api_types::CronAgentConfigWriteDto { + if let Some(value) = config.assistant_id.as_mut() { + let trimmed = value.trim().to_owned(); + if trimmed.is_empty() { + config.assistant_id = None; + } else { + *value = trimmed; + } + } config } @@ -1354,15 +1341,12 @@ mod tests { // -- validate_aionrs_agent_config ---------------------------------------- - fn agent_cfg_dto(backend: &str) -> aionui_api_types::CronAgentConfigDto { - aionui_api_types::CronAgentConfigDto { + fn agent_cfg_dto(backend: &str) -> aionui_api_types::CronAgentConfigWriteDto { + aionui_api_types::CronAgentConfigWriteDto { backend: backend.to_owned(), name: "provider".into(), cli_path: None, - is_preset: None, assistant_id: None, - custom_agent_id: None, - preset_agent_type: None, mode: None, model_id: Some("gpt-4o".into()), config_options: None, @@ -1406,14 +1390,11 @@ mod tests { #[test] fn sanitize_agent_config_dto_clears_legacy_ids_when_assistant_id_present() { - let config = aionui_api_types::CronAgentConfigDto { + let config = aionui_api_types::CronAgentConfigWriteDto { backend: "claude".into(), name: "Helper".into(), cli_path: None, - is_preset: Some(true), assistant_id: Some("assistant-1".into()), - custom_agent_id: Some("legacy-assistant".into()), - preset_agent_type: Some("claude".into()), mode: Some("default".into()), model_id: Some("claude-sonnet-4".into()), config_options: None, @@ -1423,33 +1404,23 @@ mod tests { let sanitized = sanitize_agent_config_dto(config); assert_eq!(sanitized.assistant_id.as_deref(), Some("assistant-1")); - assert!(sanitized.custom_agent_id.is_none()); - assert!(sanitized.preset_agent_type.is_none()); - assert!(sanitized.is_preset.is_none()); } #[test] fn sanitize_agent_config_dto_drops_legacy_custom_agent_id_without_assistant_id() { - let config = aionui_api_types::CronAgentConfigDto { - backend: "claude".into(), - name: "Helper".into(), - cli_path: None, - is_preset: Some(true), - assistant_id: None, - custom_agent_id: Some("legacy-assistant".into()), - preset_agent_type: Some("claude".into()), - mode: Some("default".into()), - model_id: Some("claude-sonnet-4".into()), - config_options: None, - workspace: None, - }; + let config = serde_json::from_value::(serde_json::json!({ + "backend": "claude", + "name": "Helper", + "custom_agent_id": "legacy-assistant", + "preset_agent_type": "claude", + "mode": "default", + "model_id": "claude-sonnet-4", + })) + .expect("legacy fields should deserialize as ignored unknown fields"); let sanitized = sanitize_agent_config_dto(config); assert!(sanitized.assistant_id.is_none()); - assert!(sanitized.custom_agent_id.is_none()); - assert!(sanitized.preset_agent_type.is_none()); - assert!(sanitized.is_preset.is_none()); } // -- parse_execution_mode ------------------------------------------------- @@ -1573,14 +1544,11 @@ mod tests { schedule: None, message: None, execution_mode: None, - agent_config: Some(aionui_api_types::CronAgentConfigDto { + agent_config: Some(aionui_api_types::CronAgentConfigWriteDto { backend: "claude".into(), name: "Helper".into(), cli_path: None, - is_preset: Some(true), assistant_id: Some("assistant-1".into()), - custom_agent_id: Some("legacy-assistant".into()), - preset_agent_type: Some("claude".into()), mode: Some("default".into()), model_id: Some("claude-sonnet-4".into()), config_options: None, @@ -1610,19 +1578,17 @@ mod tests { schedule: None, message: None, execution_mode: None, - agent_config: Some(aionui_api_types::CronAgentConfigDto { - backend: "claude".into(), - name: "Helper".into(), - cli_path: None, - is_preset: Some(true), - assistant_id: None, - custom_agent_id: Some("legacy-assistant".into()), - preset_agent_type: Some("claude".into()), - mode: Some("default".into()), - model_id: Some("claude-sonnet-4".into()), - config_options: None, - workspace: None, - }), + agent_config: Some( + serde_json::from_value::(serde_json::json!({ + "backend": "claude", + "name": "Helper", + "custom_agent_id": "legacy-assistant", + "preset_agent_type": "claude", + "mode": "default", + "model_id": "claude-sonnet-4", + })) + .expect("legacy fields should deserialize as ignored unknown fields"), + ), conversation_title: None, max_retries: None, }; From d3e96eb1b6220ad33fb176e74f2d15f3b9c02dba Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 11:59:15 +0800 Subject: [PATCH 042/135] refactor(conversation): expose explicit assistant identity --- crates/aionui-api-types/src/conversation.rs | 18 ++++++ crates/aionui-api-types/src/lib.rs | 10 ++-- crates/aionui-conversation/src/convert.rs | 24 +++++++- crates/aionui-conversation/src/service.rs | 56 ++++++++++++++++--- .../aionui-conversation/src/service_test.rs | 46 +++++++++++++++ 5 files changed, 139 insertions(+), 15 deletions(-) diff --git a/crates/aionui-api-types/src/conversation.rs b/crates/aionui-api-types/src/conversation.rs index f0ff2d409..478dbc64d 100644 --- a/crates/aionui-api-types/src/conversation.rs +++ b/crates/aionui-api-types/src/conversation.rs @@ -137,6 +137,15 @@ pub struct ConversationRuntimeSummary { pub turn_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ConversationAssistantIdentityResponse { + pub id: String, + pub source: String, + pub name: String, + pub avatar: String, + pub backend: String, +} + // ── Query types ──────────────────────────────────────────────────── /// Query parameters for `GET /api/conversations`. @@ -203,6 +212,8 @@ pub struct ConversationResponse { pub pinned_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub channel_chat_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assistant: Option, pub created_at: TimestampMs, pub modified_at: TimestampMs, pub extra: serde_json::Value, @@ -533,6 +544,7 @@ mod tests { pinned: false, pinned_at: None, channel_chat_id: None, + assistant: None, created_at: 1712345678000, modified_at: 1712345678000, extra: json!({ "workspace": "/project" }), @@ -570,6 +582,7 @@ mod tests { pinned: false, pinned_at: None, channel_chat_id: None, + assistant: None, created_at: 1, modified_at: 1, extra: json!({}), @@ -601,6 +614,7 @@ mod tests { pinned: true, pinned_at: Some(1712345678000), channel_chat_id: Some("group:42".into()), + assistant: None, created_at: 1000, modified_at: 2000, extra: json!({}), @@ -684,6 +698,7 @@ mod tests { pinned: false, pinned_at: None, channel_chat_id: None, + assistant: None, created_at: 1712345678000, modified_at: 1712345678000, extra: json!({}), @@ -720,6 +735,7 @@ mod tests { pinned: false, pinned_at: None, channel_chat_id: None, + assistant: None, created_at: 9000, modified_at: 9000, extra: json!({}), @@ -790,6 +806,7 @@ mod tests { pinned: false, pinned_at: None, channel_chat_id: None, + assistant: None, created_at: 1000, modified_at: 1000, extra: json!({}), @@ -834,6 +851,7 @@ mod tests { pinned: false, pinned_at: None, channel_chat_id: None, + assistant: None, created_at: 5000, modified_at: 5000, extra: json!({}), diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index db4c522ee..6b578e66a 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -76,11 +76,11 @@ pub use conversation::{ ActiveCountResponse, AssistantConversationOverridesRequest, AssistantConversationRequest, CancelConversationRequest, CancelConversationResponse, CloneConversationRequest, ConversationArtifactKind, ConversationArtifactListResponse, ConversationArtifactResponse, ConversationArtifactStatus, - ConversationListResponse, ConversationMcpStatus, ConversationMcpStatusKind, ConversationResponse, - ConversationRuntimeStateKind, ConversationRuntimeSummary, CreateConversationRequest, ListConversationsQuery, - ListMessagesQuery, MessageListResponse, MessageResponse, MessageSearchItem, MessageSearchResponse, - SearchMessagesQuery, SendMessageRequest, SendMessageResponse, UpdateConversationArtifactRequest, - UpdateConversationRequest, + ConversationAssistantIdentityResponse, ConversationListResponse, ConversationMcpStatus, ConversationMcpStatusKind, + ConversationResponse, ConversationRuntimeStateKind, ConversationRuntimeSummary, CreateConversationRequest, + ListConversationsQuery, ListMessagesQuery, MessageListResponse, MessageResponse, MessageSearchItem, + MessageSearchResponse, SearchMessagesQuery, SendMessageRequest, SendMessageResponse, + UpdateConversationArtifactRequest, UpdateConversationRequest, }; pub use cron::{ CreateCronJobRequest, CronAgentConfigDto, CronAgentConfigWriteDto, CronJobExecutedEvent, CronJobMetadataDto, diff --git a/crates/aionui-conversation/src/convert.rs b/crates/aionui-conversation/src/convert.rs index 7e8e8519e..a2905aa78 100644 --- a/crates/aionui-conversation/src/convert.rs +++ b/crates/aionui-conversation/src/convert.rs @@ -1,10 +1,14 @@ use std::path::Path; -use aionui_api_types::{ConversationArtifactResponse, ConversationResponse, MessageResponse, MessageSearchItem}; +use aionui_api_types::{ + ConversationArtifactResponse, ConversationAssistantIdentityResponse, ConversationResponse, MessageResponse, + MessageSearchItem, +}; use aionui_common::{ AgentType, ConversationSource, ConversationStatus, MessagePosition, MessageStatus, MessageType, ProviderWithModel, }; use aionui_db::MessageSearchRow; +use aionui_db::models::ConversationAssistantSnapshotRow; use aionui_db::models::{ConversationArtifactRow, ConversationRow, MessageRow}; use crate::ConversationError; @@ -71,12 +75,30 @@ pub fn row_to_response_with_extra( pinned: row.pinned, pinned_at: row.pinned_at, channel_chat_id: row.channel_chat_id, + assistant: None, created_at: row.created_at, modified_at: row.updated_at, extra, }) } +pub fn snapshot_to_assistant_identity( + snapshot: &ConversationAssistantSnapshotRow, +) -> ConversationAssistantIdentityResponse { + let avatar = match snapshot.assistant_avatar_type.as_str() { + "builtin_asset" | "user_asset" => format!("/api/assistants/{}/avatar", snapshot.assistant_key), + _ => snapshot.assistant_avatar_value.clone().unwrap_or_default(), + }; + + ConversationAssistantIdentityResponse { + id: snapshot.assistant_key.clone(), + source: snapshot.assistant_source.clone(), + name: snapshot.assistant_name.clone(), + avatar, + backend: snapshot.agent_backend.clone(), + } +} + /// Parse the model JSON column into `ProviderWithModel`. /// /// AionUi stores the full provider object (`TProviderWithModel`) which includes diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 4f63566d4..c6fadde64 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -41,7 +41,8 @@ use tracing::{debug, error, info, warn}; use crate::convert::{ TOOL_CONTENT_COMPACT_THRESHOLD_BYTES, row_to_artifact_response, row_to_message_response, - row_to_message_response_compact, row_to_response, row_to_response_with_extra, search_row_to_item, string_to_enum, + row_to_message_response_compact, row_to_response, row_to_response_with_extra, search_row_to_item, + snapshot_to_assistant_identity, string_to_enum, }; use crate::error::ConversationError; use crate::session_context::SessionContextBuilder; @@ -531,6 +532,18 @@ impl ConversationService { // ── Conversation CRUD ─────────────────────────────────────────────── impl ConversationService { + async fn attach_assistant_identity(&self, response: &mut ConversationResponse) -> Result<(), ConversationError> { + if response.assistant.is_some() { + return Ok(()); + } + + if let Some(snapshot) = self.conversation_repo.get_assistant_snapshot(&response.id).await? { + response.assistant = Some(snapshot_to_assistant_identity(&snapshot)); + } + + Ok(()) + } + /// Create a new conversation. /// /// Generates a UUID v7, sets status to `pending`, defaults source @@ -955,7 +968,19 @@ impl ConversationService { self.persist_assistant_preferences_from_snapshot(snapshot).await?; } - let response = row_to_response(row, &self.workspace_root)?; + let mut response = row_to_response(row, &self.workspace_root)?; + if let Some(snapshot) = assistant_snapshot.as_ref() { + response.assistant = Some(aionui_api_types::ConversationAssistantIdentityResponse { + id: snapshot.assistant_id.clone(), + source: snapshot.assistant_source.clone(), + name: snapshot.name.clone(), + avatar: match snapshot.avatar_type.as_str() { + "builtin_asset" | "user_asset" => format!("/api/assistants/{}/avatar", snapshot.assistant_id), + _ => snapshot.avatar.clone().unwrap_or_default(), + }, + backend: snapshot.agent_backend.clone(), + }); + } self.broadcast_list_changed(&response.id, "created", response.source.as_ref()); @@ -1463,6 +1488,7 @@ impl ConversationService { .map_err(|e| ConversationError::internal(format!("Invalid extra JSON: {e}")))?; self.backfill_extra_inplace(&row.id, &mut extra).await; let mut response = row_to_response_with_extra(row, extra, &self.workspace_root)?; + self.attach_assistant_identity(&mut response).await?; response.runtime = Some(self.runtime_summary_for(id).await); Ok(response) } @@ -1504,7 +1530,10 @@ impl ConversationService { }; self.backfill_extra_inplace(&row_id, &mut extra).await; match row_to_response_with_extra(row, extra, &self.workspace_root) { - Ok(resp) => items.push(resp), + Ok(mut resp) => { + self.attach_assistant_identity(&mut resp).await?; + items.push(resp); + } Err(err) => warn!( conversation_id = %row_id, error = %ErrorChain(&err), @@ -1838,9 +1867,13 @@ impl ConversationService { .ok_or_else(|| ConversationError::NotFound { id: id.to_owned() })?; let rows = self.conversation_repo.list_associated(user_id, id).await?; - rows.into_iter() - .map(|row| row_to_response(row, &self.workspace_root)) - .collect::, _>>() + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + let mut response = row_to_response(row, &self.workspace_root)?; + self.attach_assistant_identity(&mut response).await?; + items.push(response); + } + Ok(items) } /// List conversations spawned by a specific cron job. @@ -1850,9 +1883,13 @@ impl ConversationService { cron_job_id: &str, ) -> Result, ConversationError> { let rows = self.conversation_repo.list_by_cron_job(user_id, cron_job_id).await?; - rows.into_iter() - .map(|row| row_to_response(row, &self.workspace_root)) - .collect::, _>>() + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + let mut response = row_to_response(row, &self.workspace_root)?; + self.attach_assistant_identity(&mut response).await?; + items.push(response); + } + Ok(items) } } @@ -3383,6 +3420,7 @@ mod tests { pinned: false, pinned_at: None, channel_chat_id: None, + assistant: None, created_at: 0, modified_at: 0, extra: json!({}), diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index b215b822c..baa9201d3 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -4082,6 +4082,16 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { .unwrap(); let resp = svc.create("user-1", req).await.unwrap(); + assert_eq!( + resp.assistant, + Some(aionui_api_types::ConversationAssistantIdentityResponse { + id: "preset-1".into(), + source: "builtin".into(), + name: "Preset".into(), + avatar: "🤖".into(), + backend: "codex".into(), + }) + ); assert_eq!(resp.extra["assistant_id"], json!("preset-1")); assert!(resp.extra.get("preset_assistant_id").is_none()); assert!(resp.extra.get("preset_context").is_none()); @@ -4106,6 +4116,42 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { assert_eq!(updated_pref.last_model_id.as_deref(), Some("new-model")); assert_eq!(updated_pref.last_skill_ids, r#"["pdf"]"#); assert_eq!(updated_pref.last_disabled_builtin_skill_ids, r#"["todo-tracker"]"#); + + let fetched = svc.get("user-1", &resp.id).await.unwrap(); + assert_eq!( + fetched.assistant, + Some(aionui_api_types::ConversationAssistantIdentityResponse { + id: "preset-1".into(), + source: "builtin".into(), + name: "Preset".into(), + avatar: "🤖".into(), + backend: "codex".into(), + }) + ); + + let listed = svc + .list( + "user-1", + ListConversationsQuery { + cursor: None, + limit: Some(20), + source: None, + cron_job_id: None, + pinned: None, + }, + ) + .await + .unwrap(); + assert_eq!( + listed.items[0].assistant, + Some(aionui_api_types::ConversationAssistantIdentityResponse { + id: "preset-1".into(), + source: "builtin".into(), + name: "Preset".into(), + avatar: "🤖".into(), + backend: "codex".into(), + }) + ); } #[tokio::test] From e65ba82012e9477b46761b04fd74e1992cd6a83f Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 12:33:35 +0800 Subject: [PATCH 043/135] test(cron): stop asserting legacy preset assistant ids --- crates/aionui-app/tests/cron_e2e.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/aionui-app/tests/cron_e2e.rs b/crates/aionui-app/tests/cron_e2e.rs index cab347b3b..35af342a5 100644 --- a/crates/aionui-app/tests/cron_e2e.rs +++ b/crates/aionui-app/tests/cron_e2e.rs @@ -731,7 +731,8 @@ async fn rn1c_run_now_new_conversation_preset_assistant_uses_fixed_assistant_mcp let extra: serde_json::Value = serde_json::from_str(&conversation.extra).expect("conversation extra should be valid json"); assert_eq!(extra["assistant_id"], "u-fixed-mcp"); - assert_eq!(extra["preset_assistant_id"], "u-fixed-mcp"); + assert!(extra.get("preset_assistant_id").is_none()); + assert!(extra.get("custom_agent_id").is_none()); assert_eq!(extra["mcp_server_ids"], json!([fixed_mcp.id])); assert_eq!(extra["mcp_servers"], json!(["fixed-mcp"])); assert!( From da98c48862b026c9c503cbbeafa0805fdc4d8372 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 13:28:42 +0800 Subject: [PATCH 044/135] refactor(cron): derive runtime type from assistants --- crates/aionui-api-types/src/cron.rs | 8 +- crates/aionui-app/src/router/state.rs | 6 + crates/aionui-cron/src/service.rs | 52 +++- .../aionui-cron/tests/service_integration.rs | 255 ++++++++++++++++-- 4 files changed, 298 insertions(+), 23 deletions(-) diff --git a/crates/aionui-api-types/src/cron.rs b/crates/aionui-api-types/src/cron.rs index c1eff3196..0777c98f8 100644 --- a/crates/aionui-api-types/src/cron.rs +++ b/crates/aionui-api-types/src/cron.rs @@ -154,7 +154,7 @@ pub struct CreateCronJobRequest { pub conversation_id: String, #[serde(default)] pub conversation_title: Option, - pub agent_type: String, + pub agent_type: Option, pub created_by: String, #[serde(default)] pub execution_mode: Option, @@ -610,7 +610,7 @@ mod tests { assert_eq!(req.name, "Daily task"); assert_eq!(req.message.as_deref(), Some("Do the thing")); assert_eq!(req.conversation_id, "conv_1"); - assert_eq!(req.agent_type, "acp"); + assert_eq!(req.agent_type.as_deref(), Some("acp")); assert_eq!(req.created_by, "user"); assert_eq!(req.execution_mode.as_deref(), Some("new_conversation")); assert!(req.agent_config.is_some()); @@ -631,6 +631,7 @@ mod tests { assert!(req.prompt.is_none()); assert!(req.execution_mode.is_none()); assert!(req.agent_config.is_none()); + assert_eq!(req.agent_type.as_deref(), Some("acp")); } #[test] @@ -689,7 +690,8 @@ mod tests { "conversation_id": "c1", "created_by": "user" }); - assert!(serde_json::from_value::(raw).is_err()); + let req: CreateCronJobRequest = serde_json::from_value(raw).unwrap(); + assert!(req.agent_type.is_none()); } #[test] diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index 7ac9c81c1..b582d78bd 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -684,8 +684,14 @@ pub fn build_cron_state(services: &AppServices) -> CronRouterState { ))); let emitter = CronEventEmitter::new(services.event_bus.clone()); + let assistant_definition_repo = Arc::new(SqliteAssistantDefinitionRepository::new( + services.database.pool().clone(), + )); + let assistant_overlay_repo = Arc::new(SqliteAssistantOverlayRepository::new(services.database.pool().clone())); let cron_service = Arc::new(aionui_cron::service::CronService::new( cron_repo, + assistant_definition_repo, + assistant_overlay_repo, scheduler, executor, emitter, diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index 6533d84f0..565d52f5e 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -9,7 +9,7 @@ use aionui_api_types::{ use aionui_common::{ AgentType, WorkspacePathValidationError, generate_prefixed_id, now_ms, validate_workspace_path_availability, }; -use aionui_db::{ICronRepository, UpdateCronJobParams}; +use aionui_db::{IAssistantDefinitionRepository, IAssistantOverlayRepository, ICronRepository, UpdateCronJobParams}; use tracing::{error, info, warn}; use crate::events::CronEventEmitter; @@ -40,6 +40,8 @@ const DEPRECATED_AGENT_TYPE_MESSAGE: &str = "This agent type is no longer suppor #[derive(Clone)] pub struct CronService { repo: Arc, + assistant_definition_repo: Arc, + assistant_overlay_repo: Arc, scheduler: Arc, executor: Arc, emitter: CronEventEmitter, @@ -49,6 +51,8 @@ pub struct CronService { impl CronService { pub fn new( repo: Arc, + assistant_definition_repo: Arc, + assistant_overlay_repo: Arc, scheduler: Arc, executor: Arc, emitter: CronEventEmitter, @@ -56,6 +60,8 @@ impl CronService { ) -> Self { Self { repo, + assistant_definition_repo, + assistant_overlay_repo, scheduler, executor, emitter, @@ -70,8 +76,11 @@ impl CronService { pub async fn add_job(&self, req: CreateCronJobRequest) -> Result { let schedule = schedule_from_dto(&req.schedule); validate_schedule(&schedule)?; - reject_deprecated_new_conversation_agent_type(&req.agent_type)?; - validate_aionrs_agent_config(&req.agent_type, req.agent_config.as_ref())?; + let resolved_agent_type = self + .resolve_new_job_agent_type(req.agent_type.as_deref(), 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())?; let created_by = CreatedBy::from_str(&req.created_by)?; @@ -107,7 +116,7 @@ impl CronService { agent_config, conversation_id: req.conversation_id, conversation_title: req.conversation_title, - agent_type: req.agent_type, + agent_type: resolved_agent_type, created_by, skill_content: None, description: req.description, @@ -439,6 +448,35 @@ impl CronService { cron_job_to_response(job) } + async fn resolve_new_job_agent_type( + &self, + legacy_agent_type: Option<&str>, + agent_config: Option<&aionui_api_types::CronAgentConfigWriteDto>, + ) -> Result { + let Some(assistant_id) = agent_config.and_then(|config| config.assistant_id.as_deref()) else { + let agent_type = legacy_agent_type + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + CronError::InvalidAgentConfig("agent_type is required when assistant_id is missing".into()) + })?; + return Ok(agent_type.to_owned()); + }; + + let definition = self + .assistant_definition_repo + .get_by_key(assistant_id) + .await? + .ok_or_else(|| CronError::InvalidAgentConfig(format!("assistant '{assistant_id}' not found")))?; + let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; + let effective_backend = overlay + .as_ref() + .and_then(|item| item.agent_backend_override.as_deref()) + .unwrap_or(definition.agent_backend.as_str()); + + Ok(runtime_agent_type_for_backend(effective_backend).to_owned()) + } + async fn bind_existing_conversation_if_needed(&self, job: &CronJob) { if !matches!(job.execution_mode, ExecutionMode::Existing) || job.conversation_id.trim().is_empty() { return; @@ -891,7 +929,7 @@ impl aionui_conversation::response_middleware::ICronService for CronService { message: Some(params.message.clone()), conversation_id: conversation_id.to_owned(), conversation_title, - agent_type, + agent_type: Some(agent_type), created_by: "agent".to_owned(), execution_mode: Some("existing".to_owned()), agent_config, @@ -1114,6 +1152,10 @@ fn get_string(extra: &serde_json::Value, keys: &[&str]) -> Option { /// Aionrs cron jobs require `agent_config.backend` (provider_id) to be set — /// the executor uses it to look up the provider row and build the agent. /// Reject add/update requests that would produce an invalid aionrs job. +fn runtime_agent_type_for_backend(backend: &str) -> &'static str { + if backend == "aionrs" { "aionrs" } else { "acp" } +} + fn validate_aionrs_agent_config( agent_type: &str, agent_config: Option<&aionui_api_types::CronAgentConfigWriteDto>, diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index c36a47fad..0079537c4 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -22,9 +22,10 @@ use aionui_conversation::ConversationService; use aionui_conversation::response_middleware::{CronCreateParams, CronUpdateParams}; use aionui_db::{ ConversationFilters, ConversationRowUpdate, IAcpSessionRepository, IAgentMetadataRepository, - IConversationRepository, ICronRepository, MessageRowUpdate, MessageSearchRow, SortOrder, - SqliteAcpSessionRepository, SqliteAgentMetadataRepository, SqliteCronRepository, init_database_memory, - models::MessageRow, + IAssistantDefinitionRepository, IAssistantOverlayRepository, IConversationRepository, ICronRepository, + MessageRowUpdate, MessageSearchRow, SortOrder, SqliteAcpSessionRepository, SqliteAgentMetadataRepository, + SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, SqliteCronRepository, + UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, init_database_memory, models::MessageRow, }; use aionui_realtime::EventBroadcaster; @@ -556,6 +557,10 @@ async fn setup_with_conv_repo() -> ( let cron_repo: Arc = Arc::new(SqliteCronRepository::new(pool.clone())); let agent_metadata_repo: Arc = Arc::new(SqliteAgentMetadataRepository::new(pool.clone())); + let assistant_definition_repo: Arc = + Arc::new(SqliteAssistantDefinitionRepository::new(pool.clone())); + let assistant_overlay_repo: Arc = + Arc::new(SqliteAssistantOverlayRepository::new(pool.clone())); let acp_session_repo: Arc = Arc::new(SqliteAcpSessionRepository::new(pool)); let bc = Arc::new(MockBroadcaster::new()); let data_dir = std::env::temp_dir().join(format!("aionui-cron-test-{}", now_ms())); @@ -612,12 +617,113 @@ async fn setup_with_conv_repo() -> ( let scheduler = Arc::new(CronScheduler::new(Arc::new(|_| {}))); let emitter = CronEventEmitter::new(bc.clone() as Arc); - let svc = CronService::new(cron_repo.clone(), scheduler, executor, emitter, data_dir); + let svc = CronService::new( + cron_repo.clone(), + assistant_definition_repo, + assistant_overlay_repo, + scheduler, + executor, + emitter, + data_dir, + ); std::mem::forget(db); (svc, cron_repo, bc, stub_conv_repo) } +async fn setup_with_assistant_repos() -> ( + CronService, + Arc, + Arc, + Arc, + Arc, + Arc, +) { + let db = init_database_memory().await.unwrap(); + let pool = db.pool().clone(); + let cron_repo: Arc = Arc::new(SqliteCronRepository::new(pool.clone())); + let agent_metadata_repo: Arc = + Arc::new(SqliteAgentMetadataRepository::new(pool.clone())); + let assistant_definition_repo: Arc = + Arc::new(SqliteAssistantDefinitionRepository::new(pool.clone())); + let assistant_overlay_repo: Arc = + Arc::new(SqliteAssistantOverlayRepository::new(pool.clone())); + let acp_session_repo: Arc = Arc::new(SqliteAcpSessionRepository::new(pool)); + let bc = Arc::new(MockBroadcaster::new()); + let data_dir = std::env::temp_dir().join(format!("aionui-cron-test-{}", now_ms())); + std::fs::create_dir_all(&data_dir).unwrap(); + + struct StubSkillResolver; + #[async_trait::async_trait] + impl aionui_conversation::skill_resolver::SkillResolver for StubSkillResolver { + async fn auto_inject_names(&self) -> Vec { + Vec::new() + } + + async fn resolve_skills( + &self, + _names: &[String], + ) -> Vec { + Vec::new() + } + + async fn link_workspace_skills( + &self, + _workspace: &std::path::Path, + _rel_dirs: &[&str], + _skills: &[aionui_conversation::skill_resolver::ResolvedAgentSkill], + ) -> usize { + 0 + } + } + + let stub_conv_repo = Arc::new(StubConvRepo::new()); + let stub_conv_repo_trait: Arc = stub_conv_repo.clone(); + let task_manager: Arc = Arc::new(StubTaskManager); + let conv_service = Arc::new(ConversationService::new( + std::env::temp_dir(), + bc.clone() as Arc, + Arc::new(StubSkillResolver), + Arc::clone(&task_manager), + Arc::clone(&stub_conv_repo_trait), + Arc::clone(&agent_metadata_repo), + acp_session_repo, + )); + let agent_registry = AgentRegistry::new(agent_metadata_repo); + agent_registry.hydrate().await.unwrap(); + let executor = Arc::new(JobExecutor::new( + task_manager, + stub_conv_repo_trait, + conv_service, + data_dir.clone(), + data_dir.clone(), + bc.clone() as Arc, + agent_registry, + )); + + let scheduler = Arc::new(CronScheduler::new(Arc::new(|_| {}))); + let emitter = CronEventEmitter::new(bc.clone() as Arc); + let svc = CronService::new( + cron_repo.clone(), + assistant_definition_repo.clone(), + assistant_overlay_repo.clone(), + scheduler, + executor, + emitter, + data_dir, + ); + + std::mem::forget(db); + ( + svc, + cron_repo, + bc, + stub_conv_repo, + assistant_definition_repo, + assistant_overlay_repo, + ) +} + fn make_create_req(name: &str, schedule: CronScheduleDto) -> CreateCronJobRequest { CreateCronJobRequest { name: name.into(), @@ -627,13 +733,70 @@ fn make_create_req(name: &str, schedule: CronScheduleDto) -> CreateCronJobReques message: Some("test message".into()), conversation_id: "conv_1".into(), conversation_title: Some("Test Conv".into()), - agent_type: "acp".into(), + agent_type: Some("acp".into()), created_by: "user".into(), execution_mode: None, agent_config: None, } } +async fn seed_assistant_definition( + repo: &Arc, + definition_id: &str, + assistant_key: &str, + agent_backend: &str, +) { + repo.upsert(&UpsertAssistantDefinitionParams { + definition_id, + assistant_key, + source: "user", + owner_type: "user", + source_ref: Some(assistant_key), + source_version: None, + source_hash: None, + name: assistant_key, + name_i18n: "{}", + description: Some("test assistant"), + description_i18n: "{}", + avatar_type: "emoji", + avatar_value: Some("🤖"), + agent_backend, + rule_resource_type: "inline", + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]", + recommended_prompts_i18n: "{}", + default_model_mode: "auto", + default_model_value: None, + default_permission_mode: "auto", + default_permission_value: None, + default_skills_mode: "auto", + default_skill_ids: "[]", + custom_skill_names: "[]", + default_disabled_builtin_skill_ids: "[]", + default_mcps_mode: "auto", + default_mcp_ids: "[]", + }) + .await + .unwrap(); +} + +async fn seed_assistant_overlay( + repo: &Arc, + definition_id: &str, + agent_backend_override: Option<&str>, +) { + repo.upsert(&UpsertAssistantOverlayParams { + definition_id, + enabled: true, + sort_order: 0, + agent_backend_override, + last_used_at: None, + }) + .await + .unwrap(); +} + fn every_60s() -> CronScheduleDto { CronScheduleDto::Every { every_ms: 60000, @@ -678,16 +841,14 @@ async fn cj1_create_cron_job() { #[tokio::test] async fn create_job_strips_legacy_agent_ids_when_assistant_id_present() { - let (svc, _, _) = setup().await; + let (svc, _, _, _, definition_repo, _) = setup_with_assistant_repos().await; + seed_assistant_definition(&definition_repo, "asstdef_assistant_1", "assistant-1", "claude").await; let mut req = make_create_req("Assistant Only Create", every_60s()); - req.agent_config = Some(aionui_api_types::CronAgentConfigDto { + req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { backend: "claude".into(), name: "Helper".into(), cli_path: None, - is_preset: Some(true), assistant_id: Some("assistant-1".into()), - custom_agent_id: Some("legacy-assistant".into()), - preset_agent_type: Some("claude".into()), mode: Some("default".into()), model_id: Some("claude-sonnet-4".into()), config_options: None, @@ -709,7 +870,7 @@ async fn create_job_rejects_deprecated_agent_types() { for agent_type in ["openclaw-gateway", "nanobot", "remote", "gemini", "codex"] { let mut req = make_create_req(&format!("Deprecated {agent_type}"), every_60s()); - req.agent_type = agent_type.to_owned(); + req.agent_type = Some(agent_type.to_owned()); let err = svc.add_job(req).await.unwrap_err(); assert!(matches!(err, aionui_cron::error::CronError::InvalidAgentConfig(_))); @@ -721,6 +882,73 @@ async fn create_job_rejects_deprecated_agent_types() { } } +#[tokio::test] +async fn create_job_requires_agent_type_when_assistant_id_is_missing() { + let (svc, _, _) = setup().await; + let mut req = make_create_req("Missing Runtime Type", every_60s()); + req.agent_type = None; + + let err = svc.add_job(req).await.unwrap_err(); + assert!(matches!(err, aionui_cron::error::CronError::InvalidAgentConfig(_))); + assert!( + err.to_string() + .contains("agent_type is required when assistant_id is missing") + ); +} + +#[tokio::test] +async fn create_job_derives_runtime_type_from_aionrs_assistant() { + let (svc, _, _, _, definition_repo, _) = setup_with_assistant_repos().await; + seed_assistant_definition(&definition_repo, "asstdef_runtime_aionrs", "assistant-aionrs", "aionrs").await; + + let mut req = make_create_req("Assistant Aionrs", every_60s()); + req.agent_type = None; + req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { + backend: "provider-gemini".into(), + name: "Aionrs Assistant".into(), + cli_path: None, + assistant_id: Some("assistant-aionrs".into()), + mode: Some("yolo".into()), + model_id: Some("gemini-3.1-pro-preview".into()), + config_options: None, + workspace: None, + }); + + let job = svc.add_job(req).await.unwrap(); + + assert_eq!(job.agent_type, "aionrs"); +} + +#[tokio::test] +async fn create_job_derives_runtime_type_from_assistant_overlay_override() { + let (svc, _, _, _, definition_repo, overlay_repo) = setup_with_assistant_repos().await; + seed_assistant_definition( + &definition_repo, + "asstdef_runtime_override", + "assistant-override", + "claude", + ) + .await; + seed_assistant_overlay(&overlay_repo, "asstdef_runtime_override", Some("aionrs")).await; + + let mut req = make_create_req("Assistant Override", every_60s()); + req.agent_type = None; + req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { + backend: "provider-openai".into(), + name: "Override Assistant".into(), + cli_path: None, + assistant_id: Some("assistant-override".into()), + mode: Some("acceptEdits".into()), + model_id: Some("gpt-5.4".into()), + config_options: None, + workspace: None, + }); + + let job = svc.add_job(req).await.unwrap(); + + assert_eq!(job.agent_type, "aionrs"); +} + // ── CJ-2: Create three schedule types ────────────────────────────── #[tokio::test] @@ -871,14 +1099,11 @@ async fn update_job_strips_legacy_agent_ids_when_assistant_id_present() { schedule: None, message: None, execution_mode: None, - agent_config: Some(aionui_api_types::CronAgentConfigDto { + agent_config: Some(aionui_api_types::CronAgentConfigWriteDto { backend: "claude".into(), name: "Helper".into(), cli_path: None, - is_preset: Some(true), assistant_id: Some("assistant-1".into()), - custom_agent_id: Some("legacy-assistant".into()), - preset_agent_type: Some("claude".into()), mode: Some("default".into()), model_id: Some("claude-sonnet-4".into()), config_options: None, From 5b3d1252e9d8062f6c353cbabc70748758fd3292 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 13:40:51 +0800 Subject: [PATCH 045/135] refactor(team): pass assistant identity to conversations --- .../aionui-app/src/router/team_conversation_adapters.rs | 8 ++++++-- crates/aionui-team/src/provisioning.rs | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/aionui-app/src/router/team_conversation_adapters.rs b/crates/aionui-app/src/router/team_conversation_adapters.rs index 50526cddd..51478c3f3 100644 --- a/crates/aionui-app/src/router/team_conversation_adapters.rs +++ b/crates/aionui-app/src/router/team_conversation_adapters.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use aionui_ai_agent::IWorkerTaskManager; -use aionui_api_types::{CreateConversationRequest, WebSocketMessage}; +use aionui_api_types::{AssistantConversationRequest, CreateConversationRequest, WebSocketMessage}; use aionui_conversation::{ ConversationAgentTurnRequest, ConversationAgentTurnStarted, ConversationAgentTurnStatus, ConversationError, ConversationService, @@ -154,7 +154,11 @@ impl TeamConversationProvisioningPort for TeamConversationAdapters { r#type: request.agent_type, name: Some(request.name), model: request.top_level_model, - assistant: None, + assistant: request.assistant_id.map(|assistant_id| AssistantConversationRequest { + id: assistant_id, + locale: None, + conversation_overrides: None, + }), source: None, channel_chat_id: None, extra: request.extra, diff --git a/crates/aionui-team/src/provisioning.rs b/crates/aionui-team/src/provisioning.rs index f1ac8126e..c79da9ce9 100644 --- a/crates/aionui-team/src/provisioning.rs +++ b/crates/aionui-team/src/provisioning.rs @@ -53,6 +53,7 @@ pub struct TeamConversationCreateRequest { pub agent_type: AgentType, pub name: String, pub top_level_model: Option, + pub assistant_id: Option, pub extra: serde_json::Value, } @@ -493,6 +494,7 @@ impl TeamAgentProvisioner { agent_type, name: name.to_owned(), top_level_model, + assistant_id: assistant_id.map(str::to_owned), extra, }) .await?; From fe91dd81adbce4d0d8e461810cb55d40a5129f2b Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 13:55:29 +0800 Subject: [PATCH 046/135] refactor(team): accept assistant-first request payloads --- crates/aionui-api-types/src/team.rs | 97 +++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/crates/aionui-api-types/src/team.rs b/crates/aionui-api-types/src/team.rs index 5424e9e9b..7ed8d6308 100644 --- a/crates/aionui-api-types/src/team.rs +++ b/crates/aionui-api-types/src/team.rs @@ -1,5 +1,5 @@ use aionui_common::TimestampMs; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::TeamMcpStdioConfig; @@ -39,6 +39,7 @@ pub struct TeamAgentInput { #[derive(Debug, Deserialize)] pub struct CreateTeamRequest { pub name: String, + #[serde(alias = "assistants")] pub agents: Vec, #[serde(default)] pub workspace: Option, @@ -58,19 +59,66 @@ pub struct RenameTeamRequest { /// /// Adds a new agent to an existing team. A conversation is /// created automatically for the new agent. -#[derive(Debug, Deserialize)] +#[derive(Debug)] pub struct AddAgentRequest { pub name: String, pub role: String, - #[serde(default)] pub backend: Option, pub model: String, - #[serde(default)] pub assistant_id: Option, - #[serde(default)] pub custom_agent_id: Option, } +#[derive(Debug, Deserialize)] +struct AddAgentRequestCompat { + #[serde(default)] + assistant: Option, + #[serde(default)] + name: Option, + #[serde(default)] + role: Option, + #[serde(default)] + backend: Option, + #[serde(default)] + model: Option, + #[serde(default)] + assistant_id: Option, + #[serde(default)] + custom_agent_id: Option, +} + +impl<'de> Deserialize<'de> for AddAgentRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = AddAgentRequestCompat::deserialize(deserializer)?; + if let Some(assistant) = raw.assistant { + return Ok(Self { + name: assistant.name, + role: assistant.role, + backend: assistant.backend, + model: assistant.model, + assistant_id: assistant.assistant_id, + custom_agent_id: assistant.custom_agent_id, + }); + } + + let name = raw.name.ok_or_else(|| serde::de::Error::missing_field("name"))?; + let role = raw.role.ok_or_else(|| serde::de::Error::missing_field("role"))?; + let model = raw.model.ok_or_else(|| serde::de::Error::missing_field("model"))?; + + Ok(Self { + name, + role, + backend: raw.backend, + model, + assistant_id: raw.assistant_id, + custom_agent_id: raw.custom_agent_id, + }) + } +} + /// Request body for `PATCH /api/teams/:id/agents/:slotId/name`. #[derive(Debug, Deserialize)] pub struct RenameAgentRequest { @@ -464,6 +512,27 @@ mod tests { assert!(req.agents[1].custom_agent_id.is_none()); } + #[test] + fn deserialize_create_team_request_from_assistants_field() { + let raw = json!({ + "name": "Team Alpha", + "assistants": [ + { + "name": "Lead", + "role": "lead", + "model": "claude", + "assistant_id": "assistant-x" + } + ] + }); + let req: CreateTeamRequest = serde_json::from_value(raw).unwrap(); + assert_eq!(req.name, "Team Alpha"); + assert_eq!(req.agents.len(), 1); + assert_eq!(req.agents[0].name, "Lead"); + assert_eq!(req.agents[0].assistant_id.as_deref(), Some("assistant-x")); + assert!(req.agents[0].backend.is_none()); + } + #[test] fn deserialize_team_agent_input_with_conversation_id() { let raw = json!({ @@ -581,6 +650,24 @@ mod tests { assert_eq!(req.assistant_id.as_deref(), Some("assistant-1")); } + #[test] + fn deserialize_add_agent_request_from_assistant_field() { + let raw = json!({ + "assistant": { + "name": "Helper", + "role": "teammate", + "model": "claude", + "assistant_id": "assistant-1" + } + }); + let req: AddAgentRequest = serde_json::from_value(raw).unwrap(); + assert_eq!(req.name, "Helper"); + assert_eq!(req.role, "teammate"); + assert_eq!(req.model, "claude"); + assert_eq!(req.assistant_id.as_deref(), Some("assistant-1")); + assert!(req.backend.is_none()); + } + #[test] fn deserialize_add_agent_request_missing_name() { let raw = json!({ "role": "teammate", "backend": "acp", "model": "claude" }); From 2f5ee959a08fc5bb58eb918b3d76ed3df090d94a Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 14:36:51 +0800 Subject: [PATCH 047/135] refactor(team): drop legacy mcp spawn aliases --- crates/aionui-team/src/mcp/server.rs | 31 +++++++++++------------ crates/aionui-team/src/mcp/tools.rs | 37 ++++++++++++---------------- 2 files changed, 31 insertions(+), 37 deletions(-) diff --git a/crates/aionui-team/src/mcp/server.rs b/crates/aionui-team/src/mcp/server.rs index 8400f495a..db27ce214 100644 --- a/crates/aionui-team/src/mcp/server.rs +++ b/crates/aionui-team/src/mcp/server.rs @@ -460,9 +460,11 @@ async fn exec_list_models(args: &Value, service: &Weak) -> R } async fn exec_describe_assistant(args: &Value, service: &Weak) -> Result { + if args.get("custom_agent_id").is_some() { + return Err("custom_agent_id is no longer accepted; use assistant_id".to_owned()); + } let assistant_key = args .get("assistant_id") - .or_else(|| args.get("custom_agent_id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) @@ -601,16 +603,20 @@ async fn exec_spawn_agent( if caller_role != TeammateRole::Lead { return Err("Only Lead can spawn agents".into()); } + if args.get("backend").is_some() { + return Err("backend is no longer accepted; use agent_type".into()); + } + let input: SpawnAgentInput = serde_json::from_value(args.clone()).map_err(|e| format!("Invalid params: {e}"))?; // Requested name — normalization / emptiness / uniqueness live in // `TeamSession::spawn_agent` so we do not double-validate here. let requested_name = input.name.clone(); - // `agent_type` is the AionUi-spec field name; `backend` is the legacy - // phase-1 alias. Either (or neither — session then inherits from the - // caller) is accepted. - let agent_type = input.agent_type.or(input.backend); + // `agent_type` is the AionUi-spec fallback backend field. Assistant-first + // callers should prefer `assistant_id`; `agent_type` only remains for + // flows that intentionally spawn without an assistant. + let agent_type = input.agent_type; // Dynamic capability check happens in `TeamSession::spawn_agent` which // queries both the hard whitelist and persisted MCP capabilities. @@ -954,22 +960,15 @@ mod tests { ); } - /// The dispatch layer must accept both the new `agent_type` field and - /// the legacy `backend` alias so existing phase-1 callers (that still - /// send `backend`) do not regress. #[tokio::test] - async fn exec_spawn_agent_accepts_legacy_backend_alias() { + async fn exec_spawn_agent_rejects_legacy_backend_alias() { let service: Weak = Weak::new(); - // Use `backend` (legacy) instead of `agent_type` — parsing must succeed - // and we must reach the service-upgrade step (and then fail because - // Weak::new cannot upgrade). If `backend` were rejected at parse time - // the error would be "Invalid params". let args = json!({ "name": "Helper", "backend": "claude" }); let result = exec_spawn_agent(&args, &service, "team-1", "lead-1", TeammateRole::Lead).await; - let err = result.expect_err("dead Weak must not succeed"); + let err = result.expect_err("legacy backend alias must be rejected"); assert!( - err.contains("Team service not available"), - "legacy 'backend' alias must parse through to service-upgrade step, got {err:?}" + err.contains("backend is no longer accepted"), + "expected explicit backend alias rejection, got {err:?}" ); } } diff --git a/crates/aionui-team/src/mcp/tools.rs b/crates/aionui-team/src/mcp/tools.rs index 4f2db876a..c10e81fed 100644 --- a/crates/aionui-team/src/mcp/tools.rs +++ b/crates/aionui-team/src/mcp/tools.rs @@ -27,7 +27,7 @@ Before calling this tool in the normal planning flow: - In that approval question, remind the user that they can later ask you to replace or adjust any teammate if the lineup is not working well - Do NOT call this tool in that same turn; wait for explicit approval in a later user message -When an assistant is a good fit, prefer passing assistant_id. The legacy agent_type/backend fields remain available only for compatibility with older flows. +When an assistant is a good fit, prefer passing assistant_id. Use agent_type only when no assistant is a good fit. When calling this tool, provide the model parameter if a specific model was recommended and approved. @@ -89,7 +89,6 @@ pub fn all_tool_descriptors() -> Vec { "agent_type": { "type": "string", "description": "Fallback backend to use (e.g. \"claude\", \"codex\", \"codebuddy\", \"gemini\") when no assistant fits. Query team_list_models first to see available options." }, "model": { "type": "string", "description": "Specific model ID to use (e.g. \"claude-sonnet-4\"). Must be valid for the chosen assistant backend or fallback agent_type. Query team_list_models to see available models." }, "assistant_id": { "type": "string", "description": "Preferred assistant ID to spawn (from the Available Assistants catalog). When set, agent_type is derived from the assistant's backend." }, - "backend": { "type": "string", "description": "Legacy alias for agent_type. Prefer assistant_id first, then agent_type." }, "role": { "type": "string", "description": "Agent role (default: 'teammate')" } }, "required": ["name"] @@ -204,20 +203,18 @@ pub struct SendMessageInput { /// /// The AionUi contract (`docs/teams/phase1/aionui-audit.md` §2.1) names the /// agent-type field `agent_type` and adds `assistant_id` + `model`. The -/// phase-1 Rust dispatch originally exposed `backend` (and `role`); those are -/// preserved for back-compat and used as fallbacks when the modern fields -/// are not provided — `backend` is treated as an alias for `agent_type`. +/// phase-1 Rust dispatch originally exposed `backend` (and `role`); this +/// interface is now assistant-first and only accepts `assistant_id`, +/// `agent_type`, and `model`. #[derive(Debug, Default, Deserialize)] pub struct SpawnAgentInput { pub name: String, #[serde(default)] pub role: Option, #[serde(default)] - pub backend: Option, - #[serde(default)] pub agent_type: Option, #[serde(default)] - #[serde(alias = "assistantId", alias = "custom_agent_id", alias = "customAgentId")] + #[serde(alias = "assistantId")] pub assistant_id: Option, #[serde(default)] pub model: Option, @@ -288,8 +285,7 @@ pub fn parse_tool_call( let backend = input .agent_type .clone() - .or(input.backend.clone()) - .ok_or_else(|| "Missing 'agent_type' (or legacy 'backend') for team_spawn_agent".to_string())?; + .ok_or_else(|| "Missing 'agent_type' for team_spawn_agent".to_string())?; if !is_whitelisted_backend(&backend) { return Err(format!( "Backend '{}' not in hard whitelist. Whitelist: {}", @@ -533,7 +529,7 @@ mod tests { assert!(names.contains(&"name"), "name must be required"); assert!( !names.contains(&"backend"), - "backend should not be required (agent_type is preferred, backend is legacy alias)" + "backend should not appear in the assistant-first schema" ); } @@ -550,7 +546,7 @@ mod tests { #[test] fn parse_spawn_agent_lead_ok() { - let args = json!({"name": "Helper", "backend": "claude"}); + let args = json!({"name": "Helper", "agent_type": "claude"}); let action = parse_tool_call("team_spawn_agent", &args, TeammateRole::Lead).unwrap(); assert!(matches!( action, @@ -561,7 +557,7 @@ mod tests { #[test] fn parse_spawn_agent_teammate_rejected() { - let args = json!({"name": "X", "backend": "claude"}); + let args = json!({"name": "X", "agent_type": "claude"}); let result = parse_tool_call("team_spawn_agent", &args, TeammateRole::Teammate); assert!(result.is_err()); assert!(result.unwrap_err().contains("Only Lead")); @@ -569,7 +565,7 @@ mod tests { #[test] fn parse_spawn_agent_bad_backend() { - let args = json!({"name": "X", "backend": "malicious"}); + let args = json!({"name": "X", "agent_type": "malicious"}); let result = parse_tool_call("team_spawn_agent", &args, TeammateRole::Lead); assert!(result.is_err()); assert!(result.unwrap_err().contains("not in hard whitelist")); @@ -620,7 +616,7 @@ mod tests { #[test] fn parse_spawn_with_explicit_role() { - let args = json!({"name": "W", "role": "worker", "backend": "codex"}); + let args = json!({"name": "W", "role": "worker", "agent_type": "codex"}); let action = parse_tool_call("team_spawn_agent", &args, TeammateRole::Lead).unwrap(); assert!(matches!( action, @@ -698,7 +694,7 @@ mod tests { assert_eq!(desc.description, TEAM_DESCRIBE_ASSISTANT_DESCRIPTION); assert!( desc.description - .starts_with("Get detailed information about a preset assistant") + .starts_with("Get detailed information about an assistant") ); assert!( desc.description @@ -722,11 +718,11 @@ mod tests { } #[test] - fn parse_spawn_agent_accepts_legacy_custom_agent_id_alias() { + fn parse_spawn_agent_requires_explicit_assistant_id_field() { let input: SpawnAgentInput = serde_json::from_value(json!({ "name": "Preset helper", - "backend": "claude", - "custom_agent_id": "word-creator", + "agent_type": "claude", + "assistant_id": "word-creator", })) .unwrap(); assert_eq!(input.assistant_id.as_deref(), Some("word-creator")); @@ -741,10 +737,9 @@ mod tests { let props = desc.input_schema["properties"].as_object().unwrap(); let assistant_desc = props["assistant_id"]["description"].as_str().unwrap(); let agent_type_desc = props["agent_type"]["description"].as_str().unwrap(); - let backend_desc = props["backend"]["description"].as_str().unwrap(); assert!(assistant_desc.starts_with("Preferred assistant ID")); assert!(agent_type_desc.starts_with("Fallback backend to use")); - assert!(backend_desc.contains("Prefer assistant_id first")); + assert!(!props.contains_key("backend")); } #[test] From 513a408c8284ee2304e46ecaf140ff9e782282ee Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 15:03:59 +0800 Subject: [PATCH 048/135] refactor(team): canonicalize legacy custom agent ids --- crates/aionui-api-types/src/team.rs | 47 +++++++++---------- crates/aionui-team/src/guide/server.rs | 1 - crates/aionui-team/src/provisioning.rs | 18 ++----- crates/aionui-team/src/test_utils.rs | 1 - crates/aionui-team/src/types.rs | 2 - .../tests/session_service_integration.rs | 32 +------------ 6 files changed, 28 insertions(+), 73 deletions(-) diff --git a/crates/aionui-api-types/src/team.rs b/crates/aionui-api-types/src/team.rs index 7ed8d6308..f6e0f093a 100644 --- a/crates/aionui-api-types/src/team.rs +++ b/crates/aionui-api-types/src/team.rs @@ -21,10 +21,8 @@ pub struct TeamAgentInput { #[serde(default)] pub backend: Option, pub model: String, - #[serde(default)] + #[serde(default, alias = "custom_agent_id", alias = "customAgentId")] pub assistant_id: Option, - #[serde(default)] - pub custom_agent_id: Option, /// Adopt an existing conversation instead of creating a new one. /// When present the conversation's `extra` is updated with `teamId` /// and `backend`; no new conversation row is written. @@ -66,7 +64,6 @@ pub struct AddAgentRequest { pub backend: Option, pub model: String, pub assistant_id: Option, - pub custom_agent_id: Option, } #[derive(Debug, Deserialize)] @@ -81,10 +78,8 @@ struct AddAgentRequestCompat { backend: Option, #[serde(default)] model: Option, - #[serde(default)] + #[serde(default, alias = "custom_agent_id", alias = "customAgentId")] assistant_id: Option, - #[serde(default)] - custom_agent_id: Option, } impl<'de> Deserialize<'de> for AddAgentRequest { @@ -100,7 +95,6 @@ impl<'de> Deserialize<'de> for AddAgentRequest { backend: assistant.backend, model: assistant.model, assistant_id: assistant.assistant_id, - custom_agent_id: assistant.custom_agent_id, }); } @@ -114,7 +108,6 @@ impl<'de> Deserialize<'de> for AddAgentRequest { backend: raw.backend, model, assistant_id: raw.assistant_id, - custom_agent_id: raw.custom_agent_id, }) } } @@ -344,11 +337,13 @@ pub struct TeamAgentResponse { #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, pub model: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + alias = "custom_agent_id", + alias = "customAgentId" + )] pub assistant_id: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub custom_agent_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, #[serde(default)] pub pending_confirmations: usize, @@ -509,7 +504,6 @@ mod tests { assert_eq!(req.agents[0].assistant_id.as_deref(), Some("assistant-x")); assert_eq!(req.agents[1].name, "Worker"); assert!(req.agents[1].assistant_id.is_none()); - assert!(req.agents[1].custom_agent_id.is_none()); } #[test] @@ -546,6 +540,19 @@ mod tests { assert_eq!(input.conversation_id.as_deref(), Some("existing-conv-123")); } + #[test] + fn deserialize_team_agent_input_promotes_legacy_custom_agent_id() { + let raw = json!({ + "name": "Lead", + "role": "lead", + "backend": "acp", + "model": "claude", + "custom_agent_id": "assistant-legacy" + }); + let input: TeamAgentInput = serde_json::from_value(raw).unwrap(); + assert_eq!(input.assistant_id.as_deref(), Some("assistant-legacy")); + } + #[test] fn deserialize_team_agent_input_conversation_id_defaults_to_none() { let raw = json!({ @@ -621,7 +628,7 @@ mod tests { assert_eq!(req.role, "teammate"); assert_eq!(req.backend.as_deref(), Some("acp")); assert_eq!(req.model, "claude"); - assert!(req.custom_agent_id.is_none()); + assert!(req.assistant_id.is_none()); } #[test] @@ -634,7 +641,7 @@ mod tests { "custom_agent_id": "custom-1" }); let req: AddAgentRequest = serde_json::from_value(raw).unwrap(); - assert_eq!(req.custom_agent_id.as_deref(), Some("custom-1")); + assert_eq!(req.assistant_id.as_deref(), Some("custom-1")); } #[test] @@ -752,7 +759,6 @@ mod tests { icon: Some("/api/assets/logos/ai-major/claude.svg".into()), model: "claude".into(), assistant_id: Some("assistant-x".into()), - custom_agent_id: None, status: Some("idle".into()), pending_confirmations: 2, }; @@ -781,7 +787,6 @@ mod tests { icon: None, model: "claude".into(), assistant_id: None, - custom_agent_id: None, status: None, pending_confirmations: 0, }; @@ -806,7 +811,6 @@ mod tests { icon: Some("/api/assets/logos/ai-major/claude.svg".into()), model: "claude".into(), assistant_id: Some("assistant-x".into()), - custom_agent_id: None, status: None, pending_confirmations: 0, }], @@ -869,7 +873,6 @@ mod tests { icon: Some("/api/assets/logos/ai-major/claude.svg".into()), model: "opus".into(), assistant_id: None, - custom_agent_id: None, status: Some("idle".into()), pending_confirmations: 0, }, @@ -919,7 +922,6 @@ mod tests { icon: Some("/api/assets/logos/ai-major/claude.svg".into()), model: "claude".into(), assistant_id: Some("custom-1".into()), - custom_agent_id: Some("custom-1".into()), status: Some("working".into()), pending_confirmations: 1, }; @@ -944,7 +946,6 @@ mod tests { icon: None, model: "claude".into(), assistant_id: None, - custom_agent_id: None, status: None, pending_confirmations: 0, }, @@ -957,7 +958,6 @@ mod tests { icon: Some("/api/assets/logos/tools/coding/codex.svg".into()), model: "claude".into(), assistant_id: Some("x".into()), - custom_agent_id: Some("x".into()), status: Some("idle".into()), pending_confirmations: 3, }, @@ -996,7 +996,6 @@ mod tests { icon: None, model: "sonnet".into(), assistant_id: None, - custom_agent_id: None, status: None, pending_confirmations: 0, }, @@ -1046,7 +1045,7 @@ mod tests { let agent: TeamAgentResponse = serde_json::from_value(raw).unwrap(); assert_eq!(agent.slot_id, "s1"); assert_eq!(agent.conversation_id, "c1"); - assert_eq!(agent.custom_agent_id.as_deref(), Some("cust-1")); + assert_eq!(agent.assistant_id.as_deref(), Some("cust-1")); assert_eq!(agent.status.as_deref(), Some("idle")); assert_eq!(agent.pending_confirmations, 0); } diff --git a/crates/aionui-team/src/guide/server.rs b/crates/aionui-team/src/guide/server.rs index 330e7616b..a1dd362f9 100644 --- a/crates/aionui-team/src/guide/server.rs +++ b/crates/aionui-team/src/guide/server.rs @@ -248,7 +248,6 @@ async fn exec_create_team( backend: Some(backend.clone()), model: model.clone(), assistant_id: None, - custom_agent_id: None, conversation_id: caller_conversation_id, }], workspace: None, diff --git a/crates/aionui-team/src/provisioning.rs b/crates/aionui-team/src/provisioning.rs index c79da9ce9..361282877 100644 --- a/crates/aionui-team/src/provisioning.rs +++ b/crates/aionui-team/src/provisioning.rs @@ -96,17 +96,11 @@ pub trait TeamConversationProvisioningPort: Send + Sync { } impl TeamAgentProvisioner { - fn effective_assistant_id(assistant_id: Option<&str>, custom_agent_id: Option<&str>) -> Option { + fn effective_assistant_id(assistant_id: Option<&str>) -> Option { assistant_id .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_owned) - .or_else(|| { - custom_agent_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) - }) } pub(crate) fn new( @@ -142,10 +136,7 @@ impl TeamAgentProvisioner { let leader_slot_id = generate_id(); let leader_role = TeammateRole::Lead; - let leader_assistant_id = Self::effective_assistant_id( - leader_input.assistant_id.as_deref(), - leader_input.custom_agent_id.as_deref(), - ); + let leader_assistant_id = Self::effective_assistant_id(leader_input.assistant_id.as_deref()); let leader_backend = self .resolve_requested_backend(leader_input.backend.as_deref(), leader_assistant_id.as_deref()) .await?; @@ -193,8 +184,7 @@ impl TeamAgentProvisioner { for input in teammate_inputs { let slot_id = generate_id(); let role = TeammateRole::parse(&input.role).unwrap_or(TeammateRole::Teammate); - let assistant_id = - Self::effective_assistant_id(input.assistant_id.as_deref(), input.custom_agent_id.as_deref()); + let assistant_id = Self::effective_assistant_id(input.assistant_id.as_deref()); let backend = self .resolve_requested_backend(input.backend.as_deref(), assistant_id.as_deref()) .await?; @@ -253,7 +243,7 @@ impl TeamAgentProvisioner { ) -> Result { let role = TeammateRole::parse(&req.role).unwrap_or(TeammateRole::Teammate); let workspace = self.workspace_resolver().resolve_for_new_agent(row, team).await?; - let assistant_id = Self::effective_assistant_id(req.assistant_id.as_deref(), req.custom_agent_id.as_deref()); + let assistant_id = Self::effective_assistant_id(req.assistant_id.as_deref()); let backend = self .resolve_requested_backend(req.backend.as_deref(), assistant_id.as_deref()) .await?; diff --git a/crates/aionui-team/src/test_utils.rs b/crates/aionui-team/src/test_utils.rs index 740f9bca5..ad4d5c093 100644 --- a/crates/aionui-team/src/test_utils.rs +++ b/crates/aionui-team/src/test_utils.rs @@ -934,7 +934,6 @@ pub(crate) mod workspace_harness { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, conversation_id: None, }], workspace: None, diff --git a/crates/aionui-team/src/types.rs b/crates/aionui-team/src/types.rs index 85ef5a9e2..de848232b 100644 --- a/crates/aionui-team/src/types.rs +++ b/crates/aionui-team/src/types.rs @@ -130,7 +130,6 @@ impl TeamAgent { icon, model: self.model.clone(), assistant_id: self.assistant_id.clone(), - custom_agent_id: None, status: self.status.map(|s| s.to_string()), pending_confirmations: 0, } @@ -530,7 +529,6 @@ mod tests { assert!(resp.icon.is_none()); assert_eq!(resp.status.as_deref(), Some("working")); assert_eq!(resp.assistant_id.as_deref(), Some("custom-1")); - assert!(resp.custom_agent_id.is_none()); } #[test] diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index 43d1aa81b..1c0a28482 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -1370,7 +1370,6 @@ fn two_agent_input() -> Vec { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, conversation_id: None, }, TeamAgentInput { @@ -1379,7 +1378,6 @@ fn two_agent_input() -> Vec { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, conversation_id: None, }, ] @@ -1515,8 +1513,7 @@ async fn tc_create_team_uses_assistant_id_icon_lookup() { role: "lead".into(), backend: Some("acp".into()), model: "claude".into(), - assistant_id: None, - custom_agent_id: Some("2d23ff1c".into()), + assistant_id: Some("2d23ff1c".into()), conversation_id: None, }], workspace: None, @@ -1553,7 +1550,6 @@ async fn tc_create_team_carries_assistant_identity_into_lead_conversation_extra( backend: Some("claude".into()), model: "claude".into(), assistant_id: Some("2d23ff1c".into()), - custom_agent_id: Some("2d23ff1c".into()), conversation_id: None, }], workspace: None, @@ -1641,7 +1637,6 @@ async fn tc_create_team_derives_backend_from_assistant_when_backend_missing() { backend: Some(String::new()), model: "gpt-5".into(), assistant_id: Some("assistant-lead".into()), - custom_agent_id: None, conversation_id: None, }], workspace: None, @@ -1680,7 +1675,6 @@ async fn ta_add_agent_uses_model_fallback_for_acp_backend() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, conversation_id: None, }], workspace: None, @@ -1699,7 +1693,6 @@ async fn ta_add_agent_uses_model_fallback_for_acp_backend() { backend: Some("acp".into()), model: "codex".into(), assistant_id: None, - custom_agent_id: None, }, ) .await @@ -1776,7 +1769,6 @@ async fn ta_add_agent_derives_backend_from_assistant_when_backend_missing() { backend: Some("claude".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, conversation_id: None, }], workspace: None, @@ -1795,7 +1787,6 @@ async fn ta_add_agent_derives_backend_from_assistant_when_backend_missing() { backend: Some(String::new()), model: "gpt-5".into(), assistant_id: Some("assistant-worker".into()), - custom_agent_id: None, }, ) .await @@ -1819,7 +1810,6 @@ async fn tc2_create_single_agent_team() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, conversation_id: None, }], workspace: None, @@ -1847,7 +1837,6 @@ async fn tc4_first_agent_is_lead() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, conversation_id: None, }, TeamAgentInput { @@ -1856,7 +1845,6 @@ async fn tc4_first_agent_is_lead() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, conversation_id: None, }, ], @@ -1988,7 +1976,6 @@ async fn tl_list_teams_includes_pending_confirmation_counts_without_rebuilding_t backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, conversation_id: None, }], workspace: None, @@ -2161,7 +2148,6 @@ async fn aa1_add_agent_to_team() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, conversation_id: None, }], workspace: None, @@ -2180,7 +2166,6 @@ async fn aa1_add_agent_to_team() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, }, ) .await @@ -2213,7 +2198,6 @@ async fn aa_add_agent_inherits_team_workspace() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, conversation_id: None, }], workspace: Some(workspace.clone()), @@ -2232,7 +2216,6 @@ async fn aa_add_agent_inherits_team_workspace() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, }, ) .await @@ -2261,7 +2244,6 @@ async fn add_agent_backfills_empty_team_workspace_from_leader_workspace() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, conversation_id: None, }], workspace: None, @@ -2286,7 +2268,6 @@ async fn add_agent_backfills_empty_team_workspace_from_leader_workspace() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, }, ) .await @@ -2317,7 +2298,6 @@ async fn add_agent_uses_team_temp_workspace_when_team_and_leader_workspaces_are_ backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, conversation_id: None, }], workspace: None, @@ -2344,7 +2324,6 @@ async fn add_agent_uses_team_temp_workspace_when_team_and_leader_workspaces_are_ backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, }, ) .await @@ -2380,7 +2359,6 @@ async fn add_agent_does_not_create_teammate_when_workspace_writeback_fails() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, conversation_id: None, }], workspace: None, @@ -2403,7 +2381,6 @@ async fn add_agent_does_not_create_teammate_when_workspace_writeback_fails() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, }, ) .await @@ -2432,7 +2409,6 @@ async fn add_agent_continues_when_team_temp_leader_patch_fails() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, conversation_id: None, }], workspace: None, @@ -2462,7 +2438,6 @@ async fn add_agent_continues_when_team_temp_leader_patch_fails() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, }, ) .await @@ -2526,7 +2501,6 @@ async fn provisioning_writes_typed_team_binding_for_create_and_add_agent() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, }, ) .await @@ -2562,7 +2536,6 @@ async fn aa4_add_agent_to_nonexistent_team() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, }, ) .await; @@ -3220,7 +3193,6 @@ async fn w4_d23_concurrent_add_agent_preserves_every_insertion() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, conversation_id: None, }], workspace: None, @@ -3242,7 +3214,6 @@ async fn w4_d23_concurrent_add_agent_preserves_every_insertion() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, }, ) .await @@ -3261,7 +3232,6 @@ async fn w4_d23_concurrent_add_agent_preserves_every_insertion() { backend: Some("acp".into()), model: "claude".into(), assistant_id: None, - custom_agent_id: None, }, ) .await From e011837ac0b67342c2b6ad731558ec34cd1068a2 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 15:42:51 +0800 Subject: [PATCH 049/135] refactor(conversation): derive create type from assistants --- crates/aionui-api-types/src/conversation.rs | 27 +++- .../src/router/team_conversation_adapters.rs | 6 +- crates/aionui-app/tests/work_dir_e2e.rs | 6 +- crates/aionui-channel/src/message_service.rs | 15 +- crates/aionui-conversation/src/service.rs | 146 +++++++++++++----- .../aionui-conversation/src/service_test.rs | 112 +++++++++++++- crates/aionui-cron/src/executor.rs | 5 +- 7 files changed, 251 insertions(+), 66 deletions(-) diff --git a/crates/aionui-api-types/src/conversation.rs b/crates/aionui-api-types/src/conversation.rs index 478dbc64d..42b331147 100644 --- a/crates/aionui-api-types/src/conversation.rs +++ b/crates/aionui-api-types/src/conversation.rs @@ -51,7 +51,8 @@ pub struct AssistantConversationRequest { /// Body for `POST /api/conversations`. #[derive(Debug, Deserialize)] pub struct CreateConversationRequest { - pub r#type: AgentType, + #[serde(default)] + pub r#type: Option, pub name: Option, pub model: Option, pub assistant: Option, @@ -321,7 +322,7 @@ mod tests { "extra": { "workspace": "/project" } }); let req: CreateConversationRequest = serde_json::from_value(raw).unwrap(); - assert_eq!(req.r#type, AgentType::Acp); + assert_eq!(req.r#type, Some(AgentType::Acp)); assert_eq!(req.name.as_deref(), Some("Code Review")); assert_eq!(req.model.unwrap().model, "claude-sonnet-4-20250514"); assert_eq!( @@ -351,7 +352,7 @@ mod tests { "extra": {} }); let req: CreateConversationRequest = serde_json::from_value(raw).unwrap(); - assert_eq!(req.r#type, AgentType::Acp); + assert_eq!(req.r#type, Some(AgentType::Acp)); assert!(req.name.is_none()); assert!(req.assistant.is_none()); assert!(req.source.is_none()); @@ -365,17 +366,29 @@ mod tests { "extra": {} }); let req: CreateConversationRequest = serde_json::from_value(raw).unwrap(); - assert_eq!(req.r#type, AgentType::Acp); + assert_eq!(req.r#type, Some(AgentType::Acp)); assert!(req.model.is_none()); } #[test] - fn deserialize_create_request_missing_type() { + fn deserialize_create_request_missing_type_without_assistant() { let raw = json!({ "model": { "provider_id": "p1", "model": "m1" }, "extra": {} }); - assert!(serde_json::from_value::(raw).is_err()); + let req: CreateConversationRequest = serde_json::from_value(raw).unwrap(); + assert!(req.r#type.is_none()); + } + + #[test] + fn deserialize_create_request_missing_type_with_assistant() { + let raw = json!({ + "assistant": { "id": "assistant-1" }, + "extra": {} + }); + let req: CreateConversationRequest = serde_json::from_value(raw).unwrap(); + assert!(req.r#type.is_none()); + assert_eq!(req.assistant.unwrap().id, "assistant-1"); } #[test] @@ -453,7 +466,7 @@ mod tests { } }); let req: CloneConversationRequest = serde_json::from_value(raw).unwrap(); - assert_eq!(req.conversation.r#type, AgentType::Acp); + assert_eq!(req.conversation.r#type, Some(AgentType::Acp)); } // ── ListConversationsQuery ────────────────────────────────────── diff --git a/crates/aionui-app/src/router/team_conversation_adapters.rs b/crates/aionui-app/src/router/team_conversation_adapters.rs index 51478c3f3..d9e7d5055 100644 --- a/crates/aionui-app/src/router/team_conversation_adapters.rs +++ b/crates/aionui-app/src/router/team_conversation_adapters.rs @@ -151,7 +151,11 @@ impl TeamConversationProvisioningPort for TeamConversationAdapters { .create( &request.user_id, CreateConversationRequest { - r#type: request.agent_type, + r#type: if request.assistant_id.is_some() { + None + } else { + Some(request.agent_type) + }, name: Some(request.name), model: request.top_level_model, assistant: request.assistant_id.map(|assistant_id| AssistantConversationRequest { diff --git a/crates/aionui-app/tests/work_dir_e2e.rs b/crates/aionui-app/tests/work_dir_e2e.rs index 592719295..2434aa5af 100644 --- a/crates/aionui-app/tests/work_dir_e2e.rs +++ b/crates/aionui-app/tests/work_dir_e2e.rs @@ -20,7 +20,7 @@ async fn conversation_workspace_uses_work_dir() { let state = build_conversation_state(&services, None, None); let request = CreateConversationRequest { - r#type: AgentType::Acp, + r#type: Some(AgentType::Acp), name: Some("test".to_string()), model: None, assistant: None, @@ -58,7 +58,7 @@ async fn user_specified_workspace_is_not_overridden() { let state = build_conversation_state(&services, None, None); let request = CreateConversationRequest { - r#type: AgentType::Acp, + r#type: Some(AgentType::Acp), name: Some("test".to_string()), model: None, assistant: None, @@ -92,7 +92,7 @@ async fn workspace_defaults_to_data_dir_when_work_dir_equals_data_dir() { let state = build_conversation_state(&services, None, None); let request = CreateConversationRequest { - r#type: AgentType::Acp, + r#type: Some(AgentType::Acp), name: Some("test".to_string()), model: None, assistant: None, diff --git a/crates/aionui-channel/src/message_service.rs b/crates/aionui-channel/src/message_service.rs index 17c19e0ea..434f7bac6 100644 --- a/crates/aionui-channel/src/message_service.rs +++ b/crates/aionui-channel/src/message_service.rs @@ -127,6 +127,7 @@ impl ChannelMessageService { let source = platform_to_source(platform); let agent_config = self.settings.get_agent_config(platform).await?; let assistant_setting = self.settings.get_assistant_setting(platform).await?; + let assistant_id = assistant_setting.and_then(|setting| setting.assistant_id); let model_config = self.settings.get_model_config(platform).await?; let agent_type = parse_agent_type(&agent_config.agent_type)?; let model = resolved_model_to_provider(model_config.as_ref()); @@ -147,16 +148,14 @@ impl ChannelMessageService { }; let req = CreateConversationRequest { - r#type: agent_type, + r#type: if assistant_id.is_some() { None } else { Some(agent_type) }, name: Some(name), model: top_level_model, - assistant: assistant_setting - .and_then(|setting| setting.assistant_id) - .map(|assistant_id| AssistantConversationRequest { - id: assistant_id, - locale: None, - conversation_overrides: None, - }), + assistant: assistant_id.map(|assistant_id| AssistantConversationRequest { + id: assistant_id, + locale: None, + conversation_overrides: None, + }), source: Some(source), channel_chat_id: session.chat_id.clone(), extra, diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index c6fadde64..1e17f2a73 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -55,6 +55,26 @@ pub(crate) const MAX_CRON_CONTINUATIONS_PER_TURN: usize = 4; const ACP_CANCEL_DRAIN_TIMEOUT: Duration = Duration::from_secs(15); const LEGACY_CONVERSATION_ARCHIVED_MESSAGE: &str = "This historical conversation can no longer be continued. Please start a new conversation."; +const DEPRECATED_AGENT_TYPE_MESSAGE: &str = "This agent type is no longer supported for new conversations."; +const ACP_VENDOR_LABELS: &[&str] = &[ + "claude", + "codex", + "gemini", + "qwen", + "codebuddy", + "droid", + "goose", + "auggie", + "kimi", + "opencode", + "copilot", + "qoder", + "vibe", + "cursor", + "kiro", + "hermes", + "snow", +]; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] struct AssistantConversationOverrides { @@ -166,6 +186,51 @@ fn assistant_snapshot_modes<'a>( } } +fn parse_supported_agent_type_from_backend(backend: &str) -> Result { + if ACP_VENDOR_LABELS.contains(&backend) { + return Ok(AgentType::Acp); + } + + let quoted = format!("\"{backend}\""); + if let Ok(agent_type) = serde_json::from_str::("ed) { + if agent_type.is_deprecated_runtime() { + return Err(ConversationError::BadRequest { + reason: DEPRECATED_AGENT_TYPE_MESSAGE.into(), + }); + } + return Ok(agent_type); + } + + Err(ConversationError::BadRequest { + reason: format!("unsupported assistant backend: {backend}"), + }) +} + +fn resolve_create_agent_type( + explicit_type: Option, + assistant_snapshot: Option<&AssistantSnapshot>, +) -> Result { + if let Some(snapshot) = assistant_snapshot { + let derived = parse_supported_agent_type_from_backend(snapshot.agent_backend.trim())?; + if let Some(explicit) = explicit_type + && explicit != derived + { + warn!( + explicit_type = explicit.serde_name(), + derived_type = derived.serde_name(), + backend = snapshot.agent_backend, + assistant_id = snapshot.assistant_id, + "assistant-backed create request carried a mismatched explicit type; using assistant-derived type" + ); + } + return Ok(derived); + } + + explicit_type.ok_or_else(|| ConversationError::BadRequest { + reason: "Either `type` or `assistant.id` is required when creating a conversation.".into(), + }) +} + #[derive(Debug, Clone, Copy)] struct McpSupportPolicy { stdio: bool, @@ -548,7 +613,7 @@ impl ConversationService { /// /// Generates a UUID v7, sets status to `pending`, defaults source /// to `aionui`, and broadcasts `conversation.listChanged(created)`. - #[tracing::instrument(skip_all, fields(user_id = %user_id, agent_type = ?req.r#type))] + #[tracing::instrument(skip_all, fields(user_id = %user_id, req_type = ?req.r#type))] pub async fn create( &self, user_id: &str, @@ -558,14 +623,43 @@ impl ConversationService { let now = now_ms(); let source = req.source.unwrap_or(ConversationSource::Aionui); - if !req.r#type.supports_new_conversation() { + let mut extra = req.extra; + + let assistant_id = req + .assistant + .as_ref() + .map(|assistant| assistant.id.clone()) + .or_else(|| { + extra + .as_object() + .and_then(|obj| obj.get("preset_assistant_id")) + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + }); + let assistant_locale = req.assistant.as_ref().and_then(|assistant| assistant.locale.clone()); + let assistant_overrides = req + .assistant + .clone() + .and_then(|assistant| assistant.conversation_overrides) + .map(AssistantConversationOverrides::from) + .unwrap_or_default(); + let assistant_snapshot = match assistant_id.as_deref() { + Some(id) => { + self.resolve_assistant_snapshot(id, assistant_locale.as_deref(), &assistant_overrides, &extra) + .await? + } + None => None, + }; + let explicit_type = req.r#type; + let effective_type = resolve_create_agent_type(explicit_type, assistant_snapshot.as_ref())?; + + if !effective_type.supports_new_conversation() { info!( - agent_type = req.r#type.serde_name(), + agent_type = effective_type.serde_name(), source = ?source, "Rejected deprecated agent type for new conversation" ); return Err(ConversationError::BadRequest { - reason: "This agent type is no longer supported for new conversations.".into(), + reason: DEPRECATED_AGENT_TYPE_MESSAGE.into(), }); } @@ -573,21 +667,19 @@ impl ConversationService { // carry model/mode via `extra` (see spec 2026-05-12). Reject early so // clients that still ship the legacy shape get a loud 400 instead of // a silent write to a column nobody reads. - if req.r#type != AgentType::Aionrs && req.model.is_some() { + if effective_type != AgentType::Aionrs && req.model.is_some() { return Err(ConversationError::BadRequest { reason: format!( "top-level `model` is only accepted for aionrs conversations; pass model via `extra` for {}", - req.r#type.serde_name() + effective_type.serde_name() ), }); } - let mut extra = req.extra; - // aionrs source-of-truth rule: top-level `model` wins. If an older client // still packs `extra.model`, strip it before persist so the stored row // has a single canonical model representation. - if req.r#type == AgentType::Aionrs + if effective_type == AgentType::Aionrs && let Some(obj) = extra.as_object_mut() && obj.remove("model").is_some() { @@ -617,7 +709,7 @@ impl ConversationService { // `{data_dir}/conversations/{label}-temp-{id}/`. The label lets // operators eyeball the agent type; the conversation id keeps // the mapping back to the DB row unique. - let label = conversation_label(&req.r#type, extra.get("backend")); + let label = conversation_label(&effective_type, extra.get("backend")); let ws_path = self .workspace_root .join("conversations") @@ -636,30 +728,6 @@ impl ConversationService { obj.remove("custom_workspace"); } - let assistant_id = req - .assistant - .as_ref() - .map(|assistant| assistant.id.clone()) - .or_else(|| { - extra - .as_object() - .and_then(|obj| obj.get("preset_assistant_id")) - .and_then(|value| value.as_str().map(ToOwned::to_owned)) - }); - let assistant_locale = req.assistant.as_ref().and_then(|assistant| assistant.locale.clone()); - let assistant_overrides = req - .assistant - .clone() - .and_then(|assistant| assistant.conversation_overrides) - .map(AssistantConversationOverrides::from) - .unwrap_or_default(); - let assistant_snapshot = match assistant_id.as_deref() { - Some(id) => { - self.resolve_assistant_snapshot(id, assistant_locale.as_deref(), &assistant_overrides, &extra) - .await? - } - None => None, - }; if let Some(snapshot) = assistant_snapshot.as_ref() && let Some(obj) = extra.as_object_mut() { @@ -698,7 +766,7 @@ impl ConversationService { if !obj.contains_key("session_mode") { obj.insert("session_mode".to_owned(), serde_json::Value::String(permission.clone())); } - if matches!(req.r#type, AgentType::Acp) && !obj.contains_key("current_mode_id") { + if matches!(effective_type, AgentType::Acp) && !obj.contains_key("current_mode_id") { obj.insert( "current_mode_id".to_owned(), serde_json::Value::String(permission.clone()), @@ -762,7 +830,7 @@ impl ConversationService { && !is_custom_workspace && !initial_skills.is_empty() && let Some(rel_dirs) = - native_skills_dirs(&self.agent_metadata_repo, &req.r#type, extra.get("backend")).await + native_skills_dirs(&self.agent_metadata_repo, &effective_type, extra.get("backend")).await { let resolved = self.skill_resolver.resolve_skills(&initial_skills).await; if !resolved.is_empty() { @@ -814,7 +882,7 @@ impl ConversationService { None => None, }; - let mcp_support = self.resolve_mcp_support_policy(&req.r#type, &extra).await?; + let mcp_support = self.resolve_mcp_support_policy(&effective_type, &extra).await?; let mut selected_row_ids: Vec = Vec::new(); let mut selected_mcp_names: Vec = Vec::new(); let mut selected_mcp_statuses: Vec = Vec::new(); @@ -899,7 +967,7 @@ impl ConversationService { id: id.clone(), user_id: user_id.to_owned(), name: req.name.unwrap_or_default(), - r#type: enum_to_db(&req.r#type)?, + r#type: enum_to_db(&effective_type)?, extra: serde_json::to_string(&extra) .map_err(|e| ConversationError::internal(format!("Failed to serialize extra: {e}")))?, model: req @@ -960,7 +1028,7 @@ impl ConversationService { // ACP conversations own one `acp_session` row (1:1 by // conversation_id). Other agent types have no session-level // state so we only create it for ACP. - if req.r#type == AgentType::Acp { + if effective_type == AgentType::Acp { self.create_acp_session_row(&id, &extra).await?; } diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index baa9201d3..c144d34ea 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -854,13 +854,12 @@ async fn upsert_test_assistant_definition( async fn create_assistant_backed_conversation( svc: &ConversationService, user_id: &str, - conversation_type: &str, + conversation_type: Option<&str>, backend: &str, assistant_id: &str, ) -> ConversationResponse { let workspace = ensure_test_workspace_path(); let mut payload = json!({ - "type": conversation_type, "name": "assistant conversation", "assistant": { "id": assistant_id, @@ -872,7 +871,11 @@ async fn create_assistant_backed_conversation( } }); - if conversation_type == "aionrs" { + if let Some(conversation_type) = conversation_type { + payload["type"] = json!(conversation_type); + } + + if conversation_type == Some("aionrs") { payload["model"] = json!({ "provider_id": "provider-1", "model": "model-a", @@ -952,7 +955,7 @@ async fn create_rejects_deprecated_agent_types_for_new_conversations() { AgentType::Remote, ] { let mut req = make_create_req(); - req.r#type = agent_type; + req.r#type = Some(agent_type); req.model = None; req.extra = json!({ "workspace": ensure_test_workspace_path() @@ -1069,6 +1072,103 @@ async fn create_stores_model_as_json() { assert_eq!(model.model, "m1"); } +#[tokio::test] +async fn create_derives_aionrs_type_from_assistant_backend_when_type_is_missing() { + let resolver = Arc::new(FixedSkillResolver { names: vec![] }); + let dispatcher = Arc::new(StaticAssistantDispatcher { + rules: std::collections::HashMap::new(), + }); + let (svc, _broadcaster, repo, definition_repo, overlay_repo, _preference_repo) = + make_service_with_assistant_support(resolver, dispatcher).await; + + upsert_test_assistant_definition( + &definition_repo, + "asstdef_aionrs_missing_type", + "assistant-aionrs-missing-type", + "aionrs", + "auto", + "auto", + ) + .await; + overlay_repo + .upsert(&UpsertAssistantOverlayParams { + definition_id: "asstdef_aionrs_missing_type", + enabled: true, + sort_order: 0, + agent_backend_override: None, + last_used_at: None, + }) + .await + .unwrap(); + + let workspace = ensure_test_workspace_path(); + let req: CreateConversationRequest = serde_json::from_value(json!({ + "assistant": { + "id": "assistant-aionrs-missing-type", + "locale": "en-US" + }, + "model": { + "provider_id": "provider-1", + "model": "model-a", + "use_model": "model-a" + }, + "extra": { + "workspace": workspace + } + })) + .unwrap(); + + let resp = svc.create("user_1", req).await.unwrap(); + assert_eq!(resp.r#type, AgentType::Aionrs); + assert!(repo.get_assistant_snapshot(&resp.id).await.unwrap().is_some()); +} + +#[tokio::test] +async fn create_derives_acp_type_from_assistant_backend_when_type_is_missing() { + let resolver = Arc::new(FixedSkillResolver { names: vec![] }); + let dispatcher = Arc::new(StaticAssistantDispatcher { + rules: std::collections::HashMap::new(), + }); + let (svc, _broadcaster, repo, definition_repo, overlay_repo, _preference_repo) = + make_service_with_assistant_support(resolver, dispatcher).await; + + upsert_test_assistant_definition( + &definition_repo, + "asstdef_acp_missing_type", + "assistant-acp-missing-type", + "codex", + "auto", + "auto", + ) + .await; + overlay_repo + .upsert(&UpsertAssistantOverlayParams { + definition_id: "asstdef_acp_missing_type", + enabled: true, + sort_order: 0, + agent_backend_override: None, + last_used_at: None, + }) + .await + .unwrap(); + + let workspace = ensure_test_workspace_path(); + let req: CreateConversationRequest = serde_json::from_value(json!({ + "assistant": { + "id": "assistant-acp-missing-type", + "locale": "en-US" + }, + "extra": { + "workspace": workspace + } + })) + .unwrap(); + + let resp = svc.create("user_1", req).await.unwrap(); + assert_eq!(resp.r#type, AgentType::Acp); + assert!(repo.get_assistant_snapshot(&resp.id).await.unwrap().is_some()); +} + // ── Get tests ────────────────────────────────────────────────────── #[tokio::test] @@ -2577,7 +2677,7 @@ async fn update_aionrs_model_updates_assistant_preference_only_when_snapshot_mod .unwrap(); let auto_conv = - create_assistant_backed_conversation(&svc, "user_1", "aionrs", "aionrs", "assistant-aionrs-auto").await; + create_assistant_backed_conversation(&svc, "user_1", Some("aionrs"), "aionrs", "assistant-aionrs-auto").await; let updated = svc .update( "user_1", @@ -2638,7 +2738,7 @@ async fn update_aionrs_model_updates_assistant_preference_only_when_snapshot_mod .unwrap(); let fixed_conv = - create_assistant_backed_conversation(&svc, "user_1", "aionrs", "aionrs", "assistant-aionrs-fixed").await; + create_assistant_backed_conversation(&svc, "user_1", Some("aionrs"), "aionrs", "assistant-aionrs-fixed").await; let _ = svc .update( "user_1", diff --git a/crates/aionui-cron/src/executor.rs b/crates/aionui-cron/src/executor.rs index 1e2781c46..53bdc79d7 100644 --- a/crates/aionui-cron/src/executor.rs +++ b/crates/aionui-cron/src/executor.rs @@ -430,12 +430,13 @@ impl JobExecutor { let user_id = self.resolve_conversation_owner_user_id(job).await?; let extra = build_conversation_extra(&self.agent_registry, job, saved_skill).await; + let assistant = build_assistant_request(job); let req = CreateConversationRequest { - r#type: agent_type, + r#type: if assistant.is_some() { None } else { Some(agent_type) }, name: Some(job.name.clone()), model, - assistant: build_assistant_request(job), + assistant, source: None, channel_chat_id: None, extra, From 4f761ac771240d58f758abfd2e5266f74f6cc079 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 16:03:18 +0800 Subject: [PATCH 050/135] refactor(channel): stop persisting assistant backends in extra --- crates/aionui-channel/src/message_service.rs | 6 +- .../tests/message_service_integration.rs | 23 ++++++-- crates/aionui-conversation/src/service.rs | 57 ++++++++++++++----- 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/crates/aionui-channel/src/message_service.rs b/crates/aionui-channel/src/message_service.rs index 434f7bac6..b14131260 100644 --- a/crates/aionui-channel/src/message_service.rs +++ b/crates/aionui-channel/src/message_service.rs @@ -131,7 +131,11 @@ impl ChannelMessageService { let model_config = self.settings.get_model_config(platform).await?; let agent_type = parse_agent_type(&agent_config.agent_type)?; let model = resolved_model_to_provider(model_config.as_ref()); - let mut extra = Self::build_channel_extra(agent_config.backend.as_deref()); + let mut extra = Self::build_channel_extra(if assistant_id.is_some() { + None + } else { + agent_config.backend.as_deref() + }); let name = channel_conversation_name( platform, &agent_config.agent_type, diff --git a/crates/aionui-channel/tests/message_service_integration.rs b/crates/aionui-channel/tests/message_service_integration.rs index ab09695ba..e9109a504 100644 --- a/crates/aionui-channel/tests/message_service_integration.rs +++ b/crates/aionui-channel/tests/message_service_integration.rs @@ -14,10 +14,10 @@ use aionui_conversation::skill_resolver::{ResolvedAgentSkill, SkillResolver}; use aionui_db::models::AssistantSessionRow; use aionui_db::models::UpsertAssistantDefinitionParams; use aionui_db::{ - IAssistantDefinitionRepository, IClientPreferenceRepository, IConversationRepository, SqliteAcpSessionRepository, - SqliteAgentMetadataRepository, SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, - SqliteAssistantPreferenceRepository, SqliteClientPreferenceRepository, SqliteConversationRepository, - init_database_memory, + IAcpSessionRepository, IAssistantDefinitionRepository, IClientPreferenceRepository, IConversationRepository, + SqliteAcpSessionRepository, SqliteAgentMetadataRepository, SqliteAssistantDefinitionRepository, + SqliteAssistantOverlayRepository, SqliteAssistantPreferenceRepository, SqliteClientPreferenceRepository, + SqliteConversationRepository, init_database_memory, }; use aionui_realtime::EventBroadcaster; use async_trait::async_trait; @@ -279,6 +279,7 @@ async fn send_to_agent_persists_assistant_snapshot_for_channel_bound_assistant() let task_manager: Arc = Arc::new(RecordingTaskManager::new()); let conversation_repo = Arc::new(SqliteConversationRepository::new(pool.clone())); let conversation_repo_trait: Arc = conversation_repo.clone(); + let acp_session_repo = Arc::new(SqliteAcpSessionRepository::new(pool.clone())); let conversation_svc = Arc::new(ConversationService::new( std::env::temp_dir(), Arc::new(TestBroadcaster::new()), @@ -286,7 +287,7 @@ async fn send_to_agent_persists_assistant_snapshot_for_channel_bound_assistant() Arc::clone(&task_manager), conversation_repo_trait, Arc::new(SqliteAgentMetadataRepository::new(pool.clone())), - Arc::new(SqliteAcpSessionRepository::new(pool.clone())), + acp_session_repo.clone(), )); let pref_repo = Arc::new(SqliteClientPreferenceRepository::new(pool.clone())); @@ -347,6 +348,18 @@ async fn send_to_agent_persists_assistant_snapshot_for_channel_bound_assistant() let snapshot = snapshot.unwrap(); let conversation = conversation_repo.get(&result.conversation_id).await.unwrap().unwrap(); assert_eq!(conversation.r#type, AgentType::Acp.serde_name()); + let extra: serde_json::Value = serde_json::from_str(&conversation.extra).unwrap(); + assert!( + extra.get("backend").is_none(), + "assistant-bound channel conversations should not persist legacy extra.backend" + ); + let session_row = acp_session_repo + .get(&result.conversation_id) + .await + .unwrap() + .expect("acp_session row should exist for ACP assistant conversations"); + assert_eq!(session_row.agent_backend, "claude"); + assert!(!session_row.agent_id.is_empty()); assert_eq!(snapshot.assistant_key, "bare-claude"); assert_eq!(snapshot.agent_backend, "claude"); } diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 1e17f2a73..8adf27007 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -704,12 +704,29 @@ impl ConversationService { extra["workspace"] = serde_json::Value::String(workspace.clone()); } + let assistant_backend = assistant_snapshot + .as_ref() + .map(|snapshot| snapshot.agent_backend.clone()) + .filter(|backend| !backend.is_empty()); + let effective_backend = extra + .get("backend") + .and_then(|v| v.as_str()) + .filter(|backend| !backend.is_empty()) + .map(str::to_owned) + .or(assistant_backend); + let auto_provisioned_workspace = if user_supplied_workspace.is_none() { // Per-conversation temp workspaces live under // `{data_dir}/conversations/{label}-temp-{id}/`. The label lets // operators eyeball the agent type; the conversation id keeps // the mapping back to the DB row unique. - let label = conversation_label(&effective_type, extra.get("backend")); + let label = conversation_label( + &effective_type, + effective_backend + .as_ref() + .map(|backend| serde_json::Value::String(backend.clone())) + .as_ref(), + ); let ws_path = self .workspace_root .join("conversations") @@ -735,12 +752,6 @@ impl ConversationService { "assistant_id".to_owned(), serde_json::Value::String(snapshot.assistant_id.clone()), ); - if !snapshot.agent_backend.is_empty() && !obj.contains_key("backend") { - obj.insert( - "backend".to_owned(), - serde_json::Value::String(snapshot.agent_backend.clone()), - ); - } if let Some(agent_id) = snapshot.agent_id.as_ref() && !obj.contains_key("agent_id") { @@ -829,8 +840,15 @@ impl ConversationService { if let Some(ws_path) = auto_provisioned_workspace.as_ref() && !is_custom_workspace && !initial_skills.is_empty() - && let Some(rel_dirs) = - native_skills_dirs(&self.agent_metadata_repo, &effective_type, extra.get("backend")).await + && let Some(rel_dirs) = native_skills_dirs( + &self.agent_metadata_repo, + &effective_type, + effective_backend + .as_ref() + .map(|backend| serde_json::Value::String(backend.clone())) + .as_ref(), + ) + .await { let resolved = self.skill_resolver.resolve_skills(&initial_skills).await; if !resolved.is_empty() { @@ -1029,7 +1047,8 @@ impl ConversationService { // conversation_id). Other agent types have no session-level // state so we only create it for ACP. if effective_type == AgentType::Acp { - self.create_acp_session_row(&id, &extra).await?; + self.create_acp_session_row(&id, &extra, assistant_snapshot.as_ref()) + .await?; } if let Some(snapshot) = assistant_snapshot.as_ref() { @@ -1062,6 +1081,7 @@ impl ConversationService { &self, conversation_id: &str, extra: &serde_json::Value, + assistant_snapshot: Option<&AssistantSnapshot>, ) -> Result<(), ConversationError> { debug!("Creating acp_session row"); @@ -1071,8 +1091,17 @@ impl ConversationService { // frontend always posts agent_id for picked rows, but older // payloads may only carry `backend`, so we resolve defensively. let agent_id_from_extra = extra.get("agent_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); - let backend = extra.get("backend").and_then(|v| v.as_str()).unwrap_or_default(); - let agent_source = extra.get("agent_source").and_then(|v| v.as_str()).unwrap_or("builtin"); + let backend = extra + .get("backend") + .and_then(|v| v.as_str()) + .filter(|value| !value.is_empty()) + .or_else(|| assistant_snapshot.map(|snapshot| snapshot.agent_backend.as_str())) + .unwrap_or_default(); + let agent_source = extra + .get("agent_source") + .and_then(|v| v.as_str()) + .or_else(|| assistant_snapshot.and_then(|snapshot| snapshot.agent_source.as_deref())) + .unwrap_or("builtin"); // Fallback: older clients (electron main, legacy webhooks) only // post `backend` without `agent_id`. Resolve the builtin row for @@ -1080,7 +1109,9 @@ impl ConversationService { // reference. Non-builtin agents must provide `agent_id` // explicitly — custom/extension rows have no unique lookup key // from `(backend, agent_source)` alone. - let resolved_agent_id = match agent_id_from_extra { + let resolved_agent_id = match agent_id_from_extra + .or_else(|| assistant_snapshot.and_then(|snapshot| snapshot.agent_id.as_deref())) + { Some(id) => id.to_owned(), None if !backend.is_empty() && agent_source == "builtin" => self .agent_metadata_repo From 3fd2bbf33e6732dc74916ac32c5483421a5e380d Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 16:22:47 +0800 Subject: [PATCH 051/135] refactor(conversation): stop writing legacy assistant ids --- crates/aionui-conversation/src/service.rs | 17 ----------------- crates/aionui-conversation/src/service_test.rs | 4 +++- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 8adf27007..4e83a7abb 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -748,23 +748,6 @@ impl ConversationService { if let Some(snapshot) = assistant_snapshot.as_ref() && let Some(obj) = extra.as_object_mut() { - obj.insert( - "assistant_id".to_owned(), - serde_json::Value::String(snapshot.assistant_id.clone()), - ); - if let Some(agent_id) = snapshot.agent_id.as_ref() - && !obj.contains_key("agent_id") - { - obj.insert("agent_id".to_owned(), serde_json::Value::String(agent_id.clone())); - } - if let Some(agent_source) = snapshot.agent_source.as_ref() - && !obj.contains_key("agent_source") - { - obj.insert( - "agent_source".to_owned(), - serde_json::Value::String(agent_source.clone()), - ); - } if let Some(model_id) = snapshot.resolved_defaults.model.as_ref() && !obj.contains_key("current_model_id") { diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index c144d34ea..dea82a69c 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -4192,7 +4192,9 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { backend: "codex".into(), }) ); - assert_eq!(resp.extra["assistant_id"], json!("preset-1")); + assert!(resp.extra.get("assistant_id").is_none()); + assert!(resp.extra.get("agent_id").is_none()); + assert!(resp.extra.get("agent_source").is_none()); assert!(resp.extra.get("preset_assistant_id").is_none()); assert!(resp.extra.get("preset_context").is_none()); assert!(resp.extra.get("preset_rules").is_none()); From d6ca58aba99bca91e2c0ff351a0a5fd680cb8a30 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 16:35:09 +0800 Subject: [PATCH 052/135] refactor(cron): omit legacy agent hints for assistant creates --- crates/aionui-cron/src/executor.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/aionui-cron/src/executor.rs b/crates/aionui-cron/src/executor.rs index 53bdc79d7..94408d538 100644 --- a/crates/aionui-cron/src/executor.rs +++ b/crates/aionui-cron/src/executor.rs @@ -1129,6 +1129,7 @@ async fn build_conversation_extra( job: &CronJob, saved_skill: Option<&SavedSkillContext>, ) -> serde_json::Value { + let assistant_backed = build_assistant_request(job).is_some(); let mut extra = serde_json::Map::new(); extra.insert("cron_job_id".to_owned(), serde_json::Value::String(job.id.clone())); extra.insert("cronJobId".to_owned(), serde_json::Value::String(job.id.clone())); @@ -1144,13 +1145,15 @@ async fn build_conversation_extra( ); } - inject_agent_identity(&mut extra, registry, job).await; + if !assistant_backed { + inject_agent_identity(&mut extra, registry, job).await; + } if let Some(config) = &job.agent_config { if let Some(cli_path) = &config.cli_path { extra.insert("cli_path".to_owned(), serde_json::Value::String(cli_path.clone())); } - if !config.name.is_empty() { + if !assistant_backed && !config.name.is_empty() { extra.insert("agent_name".to_owned(), serde_json::Value::String(config.name.clone())); } if let Some(mode) = &config.mode { @@ -1716,9 +1719,10 @@ mod tests { } #[tokio::test] - async fn build_conversation_extra_omits_legacy_assistant_identity_fields_for_new_conversations() { + async fn build_conversation_extra_omits_legacy_agent_identity_fields_for_assistant_backed_new_conversations() { let registry = hydrated_registry().await; let mut job = sample_job(); + job.execution_mode = ExecutionMode::NewConversation; let config = job.agent_config.as_mut().expect("sample job should carry config"); config.assistant_id = Some("assistant-preset".into()); config.is_preset = Some(true); @@ -1729,6 +1733,9 @@ mod tests { assert!(extra.get("assistant_id").is_none()); assert!(extra.get("preset_assistant_id").is_none()); assert!(extra.get("custom_agent_id").is_none()); + assert!(extra.get("backend").is_none()); + assert!(extra.get("agent_id").is_none()); + assert!(extra.get("agent_name").is_none()); } #[test] From 03a7f5f63539ccb69daa6ad7a201475122afd3cf Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 17:13:26 +0800 Subject: [PATCH 053/135] refactor(team): derive guide leaders from assistant conversations --- .../src/router/team_conversation_adapters.rs | 22 ++ crates/aionui-team/src/guide/server.rs | 238 +++++++++++++++++- crates/aionui-team/src/provisioning.rs | 2 + crates/aionui-team/src/service.rs | 7 + crates/aionui-team/src/test_utils.rs | 49 ++++ .../tests/session_service_integration.rs | 12 + 6 files changed, 322 insertions(+), 8 deletions(-) diff --git a/crates/aionui-app/src/router/team_conversation_adapters.rs b/crates/aionui-app/src/router/team_conversation_adapters.rs index d9e7d5055..6e2c6d2a1 100644 --- a/crates/aionui-app/src/router/team_conversation_adapters.rs +++ b/crates/aionui-app/src/router/team_conversation_adapters.rs @@ -211,6 +211,28 @@ impl TeamConversationProvisioningPort for TeamConversationAdapters { .map(str::to_owned)) } + async fn conversation_assistant_id(&self, conversation_id: &str) -> Result, TeamError> { + if let Some(snapshot) = self.conversation_repo.get_assistant_snapshot(conversation_id).await? { + let assistant_id = snapshot.assistant_key.trim(); + if !assistant_id.is_empty() { + return Ok(Some(assistant_id.to_owned())); + } + } + + let Some(row) = self.conversation_repo.get(conversation_id).await? else { + return Ok(None); + }; + + let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap_or(serde_json::Value::Null); + Ok(extra + .get("assistant_id") + .or_else(|| extra.get("preset_assistant_id")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned)) + } + async fn create_team_temp_workspace(&self, team_id: &str) -> Result { self.conversation_service .create_team_temp_workspace(team_id) diff --git a/crates/aionui-team/src/guide/server.rs b/crates/aionui-team/src/guide/server.rs index a1dd362f9..1951735d0 100644 --- a/crates/aionui-team/src/guide/server.rs +++ b/crates/aionui-team/src/guide/server.rs @@ -194,12 +194,6 @@ async fn exec_create_team( } }; - let backend = request_body - .get("backend") - .and_then(serde_json::Value::as_str) - .unwrap_or("claude") - .to_owned(); - let model = request_body .get("model") .and_then(serde_json::Value::as_str) @@ -218,6 +212,15 @@ async fn exec_create_team( .filter(|s| !s.is_empty()) .map(str::to_owned); + let assistant_id = + match resolve_requested_assistant_id(&svc, request_body, args, caller_conversation_id.as_deref()).await { + Ok(assistant_id) => assistant_id, + Err(error) => { + warn!(error, "Guide HTTP: aion_create_team missing assistant identity"); + return serde_json::json!({ "error": error }); + } + }; + // Refuse if the caller conversation already belongs to a team. // This prevents duplicate team creation when guide MCP is // erroneously injected into an existing team leader session. @@ -245,9 +248,9 @@ async fn exec_create_team( agents: vec![TeamAgentInput { name: "Leader".to_owned(), role: "leader".to_owned(), - backend: Some(backend.clone()), + backend: None, model: model.clone(), - assistant_id: None, + assistant_id: Some(assistant_id), conversation_id: caller_conversation_id, }], workspace: None, @@ -276,6 +279,43 @@ async fn exec_create_team( }) } +fn extract_assistant_id(value: &serde_json::Value) -> Option { + value + .get("assistant_id") + .or_else(|| value.get("custom_agent_id")) + .and_then(serde_json::Value::as_str) + .or_else(|| { + value + .get("assistant") + .and_then(|assistant| assistant.get("id")) + .and_then(serde_json::Value::as_str) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + +async fn resolve_requested_assistant_id( + service: &Arc, + request_body: &serde_json::Value, + args: &serde_json::Value, + caller_conversation_id: Option<&str>, +) -> Result { + if let Some(assistant_id) = extract_assistant_id(request_body).or_else(|| extract_assistant_id(args)) { + return Ok(assistant_id); + } + + let Some(conversation_id) = caller_conversation_id else { + return Err("assistant_id is required when the caller conversation is not assistant-backed".into()); + }; + + service + .lookup_assistant_identity_by_conversation(conversation_id) + .await + .map_err(|error| format!("failed to resolve caller assistant identity: {error}"))? + .ok_or_else(|| "assistant_id is required when the caller conversation is not assistant-backed".into()) +} + async fn exec_team_tool( tool_name: &str, request_body: &serde_json::Value, @@ -381,9 +421,84 @@ async fn resolve_team_context(service: &TeamSessionService, conversation_id: &st #[cfg(test)] mod tests { use super::*; + use std::sync::Arc; use std::time::Duration; + + use aionui_db::models::{AssistantDefinitionRow, AssistantOverlayRow, ConversationRow}; + use aionui_db::{ + IAssistantDefinitionRepository, IAssistantOverlayRepository, IConversationRepository, ITeamRepository, + }; use tokio::time::timeout; + use crate::test_utils::workspace_harness::setup_with_assistants_team_repo_and_conversation_repo; + + struct SingleAssistantDefinitionRepo { + row: AssistantDefinitionRow, + } + + #[async_trait::async_trait] + impl IAssistantDefinitionRepository for SingleAssistantDefinitionRepo { + async fn list(&self) -> Result, aionui_db::DbError> { + Ok(vec![self.row.clone()]) + } + + async fn get_by_key(&self, assistant_key: &str) -> Result, aionui_db::DbError> { + Ok((self.row.assistant_key == assistant_key).then_some(self.row.clone())) + } + + async fn get_by_definition_id( + &self, + definition_id: &str, + ) -> Result, aionui_db::DbError> { + Ok((self.row.definition_id == definition_id).then_some(self.row.clone())) + } + + async fn get_by_source_ref( + &self, + _source: &str, + _source_ref: &str, + ) -> Result, aionui_db::DbError> { + Ok(None) + } + + async fn upsert( + &self, + _params: &aionui_db::models::UpsertAssistantDefinitionParams<'_>, + ) -> Result { + Err(aionui_db::DbError::Init("not implemented".into())) + } + + async fn soft_delete(&self, _definition_id: &str, _deleted_at: i64) -> Result { + Ok(false) + } + } + + struct SingleAssistantOverlayRepo { + row: AssistantOverlayRow, + } + + #[async_trait::async_trait] + impl IAssistantOverlayRepository for SingleAssistantOverlayRepo { + async fn get(&self, definition_id: &str) -> Result, aionui_db::DbError> { + Ok((self.row.definition_id == definition_id).then_some(self.row.clone())) + } + + async fn list(&self) -> Result, aionui_db::DbError> { + Ok(vec![self.row.clone()]) + } + + async fn upsert( + &self, + _params: &aionui_db::models::UpsertAssistantOverlayParams<'_>, + ) -> Result { + Err(aionui_db::DbError::Init("not implemented".into())) + } + + async fn delete(&self, _definition_id: &str) -> Result { + Ok(false) + } + } + #[tokio::test] async fn start_returns_positive_port_and_token() { let server = GuideMcpServer::start().await.expect("start should succeed"); @@ -466,4 +581,111 @@ mod tests { let body: serde_json::Value = resp.json().await.unwrap(); assert!(body.get("result").is_some()); } + + #[tokio::test] + async fn create_team_uses_assistant_identity_from_caller_conversation() { + let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { + row: AssistantDefinitionRow { + definition_id: "def-guide-lead".into(), + assistant_key: "assistant-lead".into(), + source: "user".into(), + owner_type: "user".into(), + source_ref: None, + source_version: None, + source_hash: None, + name: "Lead Assistant".into(), + name_i18n: "{}".into(), + description: None, + description_i18n: "{}".into(), + avatar_type: "emoji".into(), + avatar_value: Some("🤖".into()), + agent_backend: "claude".into(), + rule_resource_type: "inline".into(), + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]".into(), + recommended_prompts_i18n: "{}".into(), + default_model_mode: "auto".into(), + default_model_value: None, + default_permission_mode: "auto".into(), + default_permission_value: None, + default_skills_mode: "auto".into(), + default_skill_ids: "[]".into(), + custom_skill_names: "[]".into(), + default_disabled_builtin_skill_ids: "[]".into(), + default_mcps_mode: "auto".into(), + default_mcp_ids: "[]".into(), + created_at: 0, + updated_at: 0, + deleted_at: None, + }, + }); + let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { + row: AssistantOverlayRow { + definition_id: "def-guide-lead".into(), + enabled: true, + sort_order: 0, + agent_backend_override: Some("codex".into()), + last_used_at: None, + created_at: 0, + updated_at: 0, + }, + }); + let (svc, team_repo, _task_manager, conv_repo) = + setup_with_assistants_team_repo_and_conversation_repo(definition_repo, overlay_repo); + + conv_repo + .create(&ConversationRow { + id: "caller-conv".into(), + user_id: "system_default_user".into(), + name: "Caller".into(), + r#type: "acp".into(), + pinned: false, + pinned_at: None, + source: None, + channel_chat_id: None, + extra: serde_json::json!({ + "assistant_id": "assistant-lead", + "workspace": "/tmp/guide-workspace" + }) + .to_string(), + model: None, + status: Some("completed".into()), + created_at: 0, + updated_at: 0, + }) + .await + .expect("seed caller conversation"); + + let server = GuideMcpServer::start().await.expect("start guide server"); + server.set_service(Arc::downgrade(&svc)).await; + + let client = reqwest::Client::new(); + let resp = client + .post(format!("http://127.0.0.1:{}/tool", server.http_port())) + .header("Authorization", format!("Bearer {}", server.auth_token())) + .json(&serde_json::json!({ + "tool": "aion_create_team", + "args": { "summary": "build a review team" }, + "conversation_id": "caller-conv", + "user_id": "system_default_user" + })) + .send() + .await + .expect("call guide create team"); + assert_eq!(resp.status(), StatusCode::OK); + + let body: serde_json::Value = resp.json().await.expect("guide create team response"); + let team_id = body["teamId"].as_str().expect("team id in response"); + let team_row = team_repo + .get_team(team_id) + .await + .expect("team lookup") + .expect("persisted team row"); + let team = crate::types::Team::from_row(&team_row).expect("team row parses"); + let leader = team.agents.first().expect("leader agent exists"); + + assert_eq!(leader.assistant_id.as_deref(), Some("assistant-lead")); + assert_eq!(leader.backend, "codex"); + } } diff --git a/crates/aionui-team/src/provisioning.rs b/crates/aionui-team/src/provisioning.rs index 361282877..2e2fcdb66 100644 --- a/crates/aionui-team/src/provisioning.rs +++ b/crates/aionui-team/src/provisioning.rs @@ -79,6 +79,8 @@ pub trait TeamConversationProvisioningPort: Send + Sync { async fn conversation_workspace(&self, conversation_id: &str) -> Result, TeamError>; + async fn conversation_assistant_id(&self, conversation_id: &str) -> Result, TeamError>; + async fn create_team_temp_workspace(&self, team_id: &str) -> Result; async fn patch_runtime_config(&self, conversation_id: &str, patch: serde_json::Value) -> Result<(), TeamError>; diff --git a/crates/aionui-team/src/service.rs b/crates/aionui-team/src/service.rs index 9cb4cc764..e8425a02b 100644 --- a/crates/aionui-team/src/service.rs +++ b/crates/aionui-team/src/service.rs @@ -136,6 +136,13 @@ impl TeamSessionService { .await } + pub(crate) async fn lookup_assistant_identity_by_conversation( + &self, + conversation_id: &str, + ) -> Result, TeamError> { + self.conversation_port.conversation_assistant_id(conversation_id).await + } + async fn load_owned_team(&self, user_id: &str, team_id: &str) -> Result { let row = self .repo diff --git a/crates/aionui-team/src/test_utils.rs b/crates/aionui-team/src/test_utils.rs index ad4d5c093..cd4d0f353 100644 --- a/crates/aionui-team/src/test_utils.rs +++ b/crates/aionui-team/src/test_utils.rs @@ -569,6 +569,18 @@ pub(crate) mod workspace_harness { })) } + async fn conversation_assistant_id(&self, conversation_id: &str) -> Result, TeamError> { + Ok(self.repo.get_extra(conversation_id).and_then(|extra| { + extra + .get("assistant_id") + .or_else(|| extra.get("preset_assistant_id")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + })) + } + async fn create_team_temp_workspace(&self, team_id: &str) -> Result { let path = self .workspace_root @@ -913,6 +925,43 @@ pub(crate) mod workspace_harness { (svc, team_repo, task_manager, conv_repo) } + pub(crate) fn setup_with_assistants_team_repo_and_conversation_repo( + assistant_definition_repo: Arc, + assistant_overlay_repo: Arc, + ) -> ( + Arc, + Arc, + Arc, + Arc, + ) { + let team_repo = Arc::new(FullMockTeamRepo::new()); + let team_repo_dyn: Arc = team_repo.clone(); + let conv_repo = Arc::new(MockConversationRepo::new()); + let broadcaster: Arc = Arc::new(NullBroadcaster); + let conversation_ports = Arc::new(FakeConversationPorts::new(conv_repo.clone())); + let conversation_port: Arc = conversation_ports.clone(); + let projection_store: Arc = conversation_ports.clone(); + let lookup_port: Arc = conversation_ports; + let task_manager: Arc = Arc::new(NoopTaskManager); + let svc = TeamSessionService::new( + team_repo_dyn, + Arc::new(EmptyAgentMetadataRepo), + assistant_definition_repo, + assistant_overlay_repo, + Arc::new(EmptyProviderRepo), + conversation_port, + projection_store, + lookup_port, + broadcaster, + task_manager.clone(), + Arc::new(NoopTurnPort), + Arc::new(NoopCancellationPort), + Arc::new(std::path::PathBuf::from("/tmp/aioncore-test")), + None, + ); + (svc, team_repo, task_manager, conv_repo) + } + pub(crate) async fn force_team_workspace(repo: &Arc, team_id: &str, workspace: &str) { repo.update_team( team_id, diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index 1c0a28482..9f7dafd3d 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -348,6 +348,18 @@ impl TeamConversationProvisioningPort for FakeConversationPorts { })) } + async fn conversation_assistant_id(&self, conversation_id: &str) -> Result, aionui_team::TeamError> { + Ok(self.repo.get_extra(conversation_id).and_then(|extra| { + extra + .get("assistant_id") + .or_else(|| extra.get("preset_assistant_id")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + })) + } + async fn create_team_temp_workspace(&self, team_id: &str) -> Result { if self .fail_team_temp_create From 0691319b6dc155450ad475fbf01efd58651681bc Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 17:31:59 +0800 Subject: [PATCH 054/135] refactor(team): require assistant ids for mcp spawning --- crates/aionui-team/src/mcp/server.rs | 53 +++++--- crates/aionui-team/src/mcp/tools.rs | 97 +++++---------- .../aionui-team/src/service/spawn_support.rs | 9 +- crates/aionui-team/src/session.rs | 113 ++++-------------- .../tests/mcp_server_integration.rs | 7 +- .../tests/session_service_integration.rs | 3 +- 6 files changed, 97 insertions(+), 185 deletions(-) diff --git a/crates/aionui-team/src/mcp/server.rs b/crates/aionui-team/src/mcp/server.rs index db27ce214..7846f4930 100644 --- a/crates/aionui-team/src/mcp/server.rs +++ b/crates/aionui-team/src/mcp/server.rs @@ -604,27 +604,28 @@ async fn exec_spawn_agent( return Err("Only Lead can spawn agents".into()); } if args.get("backend").is_some() { - return Err("backend is no longer accepted; use agent_type".into()); + return Err("backend is no longer accepted; use assistant_id".into()); + } + if args.get("agent_type").is_some() { + return Err("agent_type is no longer accepted; use assistant_id".into()); } let input: SpawnAgentInput = serde_json::from_value(args.clone()).map_err(|e| format!("Invalid params: {e}"))?; + let assistant_id = input + .assistant_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| "Missing required field: assistant_id".to_owned())?; // Requested name — normalization / emptiness / uniqueness live in // `TeamSession::spawn_agent` so we do not double-validate here. let requested_name = input.name.clone(); - // `agent_type` is the AionUi-spec fallback backend field. Assistant-first - // callers should prefer `assistant_id`; `agent_type` only remains for - // flows that intentionally spawn without an assistant. - let agent_type = input.agent_type; - - // Dynamic capability check happens in `TeamSession::spawn_agent` which - // queries both the hard whitelist and persisted MCP capabilities. - let req = SpawnAgentRequest { name: requested_name.clone(), - agent_type, - assistant_id: input.assistant_id, + assistant_id: Some(assistant_id), model: input.model, }; @@ -929,8 +930,8 @@ mod tests { #[tokio::test] async fn exec_spawn_agent_rejects_malformed_args() { let service: Weak = Weak::new(); - // `name` missing entirely — SpawnAgentInput requires it. - let args = json!({ "agent_type": "claude" }); + // Wrong `name` type so serde fails before any service lookup. + let args = json!({ "assistant_id": "word-creator", "name": 42 }); let result = exec_spawn_agent(&args, &service, "team-1", "lead-1", TeammateRole::Lead).await; let err = result.expect_err("malformed args must be rejected"); assert!( @@ -949,7 +950,7 @@ mod tests { let service: Weak = Weak::new(); let args = json!({ "name": "Helper", - "agent_type": "claude", + "assistant_id": "word-creator", "model": "claude-sonnet-4" }); let result = exec_spawn_agent(&args, &service, "team-1", "lead-1", TeammateRole::Lead).await; @@ -971,4 +972,28 @@ mod tests { "expected explicit backend alias rejection, got {err:?}" ); } + + #[tokio::test] + async fn exec_spawn_agent_rejects_legacy_agent_type_alias() { + let service: Weak = Weak::new(); + let args = json!({ "name": "Helper", "agent_type": "claude" }); + let result = exec_spawn_agent(&args, &service, "team-1", "lead-1", TeammateRole::Lead).await; + let err = result.expect_err("legacy agent_type alias must be rejected"); + assert!( + err.contains("agent_type is no longer accepted"), + "expected explicit agent_type rejection, got {err:?}" + ); + } + + #[tokio::test] + async fn exec_spawn_agent_requires_assistant_identity() { + let service: Weak = Weak::new(); + let args = json!({ "name": "Helper" }); + let result = exec_spawn_agent(&args, &service, "team-1", "lead-1", TeammateRole::Lead).await; + let err = result.expect_err("assistant_id must now be required"); + assert!( + err.contains("Missing required field: assistant_id"), + "expected assistant_id requirement, got {err:?}" + ); + } } diff --git a/crates/aionui-team/src/mcp/tools.rs b/crates/aionui-team/src/mcp/tools.rs index c10e81fed..176f4c2fd 100644 --- a/crates/aionui-team/src/mcp/tools.rs +++ b/crates/aionui-team/src/mcp/tools.rs @@ -27,8 +27,7 @@ Before calling this tool in the normal planning flow: - In that approval question, remind the user that they can later ask you to replace or adjust any teammate if the lineup is not working well - Do NOT call this tool in that same turn; wait for explicit approval in a later user message -When an assistant is a good fit, prefer passing assistant_id. Use agent_type only when no assistant is a good fit. - +When calling this tool, always provide assistant_id from the available assistants catalog. When calling this tool, provide the model parameter if a specific model was recommended and approved. The new agent will be created and added to the team. You can then assign tasks and send messages to it."#; @@ -86,12 +85,11 @@ pub fn all_tool_descriptors() -> Vec { "type": "object", "properties": { "name": { "type": "string", "description": "Agent display name" }, - "agent_type": { "type": "string", "description": "Fallback backend to use (e.g. \"claude\", \"codex\", \"codebuddy\", \"gemini\") when no assistant fits. Query team_list_models first to see available options." }, - "model": { "type": "string", "description": "Specific model ID to use (e.g. \"claude-sonnet-4\"). Must be valid for the chosen assistant backend or fallback agent_type. Query team_list_models to see available models." }, - "assistant_id": { "type": "string", "description": "Preferred assistant ID to spawn (from the Available Assistants catalog). When set, agent_type is derived from the assistant's backend." }, + "model": { "type": "string", "description": "Specific model ID to use (e.g. \"claude-sonnet-4\"). Must be valid for the chosen assistant backend. Query team_list_models to see available models." }, + "assistant_id": { "type": "string", "description": "Assistant ID to spawn (from the Available Assistants catalog). The runtime backend is derived from this assistant." }, "role": { "type": "string", "description": "Agent role (default: 'teammate')" } }, - "required": ["name"] + "required": ["name", "assistant_id"] }), }, ToolDescriptor { @@ -201,19 +199,14 @@ pub struct SendMessageInput { /// Arguments for the `team_spawn_agent` MCP tool call. /// -/// The AionUi contract (`docs/teams/phase1/aionui-audit.md` §2.1) names the -/// agent-type field `agent_type` and adds `assistant_id` + `model`. The -/// phase-1 Rust dispatch originally exposed `backend` (and `role`); this -/// interface is now assistant-first and only accepts `assistant_id`, -/// `agent_type`, and `model`. +/// Team spawning is assistant-first. The MCP tool only accepts +/// `assistant_id`, optional `model`, and optional `role`. #[derive(Debug, Default, Deserialize)] pub struct SpawnAgentInput { pub name: String, #[serde(default)] pub role: Option, #[serde(default)] - pub agent_type: Option, - #[serde(default)] #[serde(alias = "assistantId")] pub assistant_id: Option, #[serde(default)] @@ -265,7 +258,7 @@ pub fn is_whitelisted_backend(backend: &str) -> bool { pub fn parse_tool_call( tool_name: &str, arguments: &Value, - caller_role: TeammateRole, + _caller_role: TeammateRole, ) -> Result { match tool_name { "team_send_message" => { @@ -276,29 +269,7 @@ pub fn parse_tool_call( message: input.message, }) } - "team_spawn_agent" => { - if caller_role != TeammateRole::Lead { - return Err("Only Lead can spawn agents".into()); - } - let input: SpawnAgentInput = serde_json::from_value(arguments.clone()) - .map_err(|e| format!("Invalid arguments for team_spawn_agent: {e}"))?; - let backend = input - .agent_type - .clone() - .ok_or_else(|| "Missing 'agent_type' for team_spawn_agent".to_string())?; - if !is_whitelisted_backend(&backend) { - return Err(format!( - "Backend '{}' not in hard whitelist. Whitelist: {}", - backend, - aionui_common::constants::TEAM_CAPABLE_BACKENDS.join(", ") - )); - } - Ok(SchedulerAction::SpawnAgent { - name: input.name, - role: input.role.unwrap_or_else(|| "teammate".into()), - backend, - }) - } + "team_spawn_agent" => Err("handled directly by server".into()), "team_task_create" => { let input: TaskCreateInput = serde_json::from_value(arguments.clone()) .map_err(|e| format!("Invalid arguments for team_task_create: {e}"))?; @@ -501,21 +472,21 @@ mod tests { } #[test] - fn team_spawn_agent_schema_exposes_model_and_agent_type() { + fn team_spawn_agent_schema_exposes_model_and_assistant_id_only() { let desc = all_tool_descriptors() .into_iter() .find(|d| d.name == "team_spawn_agent") .unwrap(); let props = desc.input_schema["properties"].as_object().unwrap(); assert!(props.contains_key("model"), "schema must expose 'model' field"); - assert!( - props.contains_key("agent_type"), - "schema must expose 'agent_type' field" - ); assert!( props.contains_key("assistant_id"), "schema must expose 'assistant_id' field" ); + assert!( + !props.contains_key("agent_type"), + "assistant-first schema must not expose 'agent_type'" + ); } #[test] @@ -545,30 +516,27 @@ mod tests { } #[test] - fn parse_spawn_agent_lead_ok() { - let args = json!({"name": "Helper", "agent_type": "claude"}); - let action = parse_tool_call("team_spawn_agent", &args, TeammateRole::Lead).unwrap(); - assert!(matches!( - action, - SchedulerAction::SpawnAgent { name, backend, role } - if name == "Helper" && backend == "claude" && role == "teammate" - )); + fn parse_spawn_agent_is_handled_directly_by_server() { + let args = json!({"name": "Helper", "assistant_id": "word-creator"}); + let result = parse_tool_call("team_spawn_agent", &args, TeammateRole::Lead); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("handled directly by server")); } #[test] fn parse_spawn_agent_teammate_rejected() { - let args = json!({"name": "X", "agent_type": "claude"}); + let args = json!({"name": "X", "assistant_id": "word-creator"}); let result = parse_tool_call("team_spawn_agent", &args, TeammateRole::Teammate); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Only Lead")); + assert!(result.unwrap_err().contains("handled directly by server")); } #[test] - fn parse_spawn_agent_bad_backend() { + fn parse_spawn_agent_with_legacy_agent_type_is_handled_directly_by_server() { let args = json!({"name": "X", "agent_type": "malicious"}); let result = parse_tool_call("team_spawn_agent", &args, TeammateRole::Lead); assert!(result.is_err()); - assert!(result.unwrap_err().contains("not in hard whitelist")); + assert!(result.unwrap_err().contains("handled directly by server")); } #[test] @@ -615,14 +583,11 @@ mod tests { } #[test] - fn parse_spawn_with_explicit_role() { - let args = json!({"name": "W", "role": "worker", "agent_type": "codex"}); - let action = parse_tool_call("team_spawn_agent", &args, TeammateRole::Lead).unwrap(); - assert!(matches!( - action, - SchedulerAction::SpawnAgent { role, .. } - if role == "worker" - )); + fn parse_spawn_with_explicit_role_is_handled_directly_by_server() { + let args = json!({"name": "W", "role": "worker", "assistant_id": "word-creator"}); + let result = parse_tool_call("team_spawn_agent", &args, TeammateRole::Lead); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("handled directly by server")); } #[test] @@ -721,7 +686,6 @@ mod tests { fn parse_spawn_agent_requires_explicit_assistant_id_field() { let input: SpawnAgentInput = serde_json::from_value(json!({ "name": "Preset helper", - "agent_type": "claude", "assistant_id": "word-creator", })) .unwrap(); @@ -729,16 +693,15 @@ mod tests { } #[test] - fn team_spawn_agent_schema_prefers_assistant_id_over_agent_type() { + fn team_spawn_agent_schema_requires_assistant_id_only() { let desc = all_tool_descriptors() .into_iter() .find(|d| d.name == "team_spawn_agent") .unwrap(); let props = desc.input_schema["properties"].as_object().unwrap(); let assistant_desc = props["assistant_id"]["description"].as_str().unwrap(); - let agent_type_desc = props["agent_type"]["description"].as_str().unwrap(); - assert!(assistant_desc.starts_with("Preferred assistant ID")); - assert!(agent_type_desc.starts_with("Fallback backend to use")); + assert!(assistant_desc.starts_with("Assistant ID to spawn")); + assert!(!props.contains_key("agent_type")); assert!(!props.contains_key("backend")); } diff --git a/crates/aionui-team/src/service/spawn_support.rs b/crates/aionui-team/src/service/spawn_support.rs index 3ca452a03..e4e5dc684 100644 --- a/crates/aionui-team/src/service/spawn_support.rs +++ b/crates/aionui-team/src/service/spawn_support.rs @@ -66,7 +66,6 @@ impl TeamSessionService { pub(crate) async fn resolve_spawn_backend_and_model( &self, assistant_id: Option<&str>, - requested_backend: Option<&str>, requested_model: Option<&str>, fallback_backend: &str, fallback_model: &str, @@ -101,11 +100,7 @@ impl TeamSessionService { return Ok((backend, model)); } - let backend = requested_backend - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(fallback_backend) - .to_owned(); + let backend = fallback_backend.to_owned(); let requested_model = requested_model .map(str::trim) .filter(|value| !value.is_empty()) @@ -585,7 +580,7 @@ mod tests { ); let (backend, model) = svc - .resolve_spawn_backend_and_model(Some("word-creator"), None, None, "gemini", "gemini-2.5-pro") + .resolve_spawn_backend_and_model(Some("word-creator"), None, "gemini", "gemini-2.5-pro") .await .unwrap(); diff --git a/crates/aionui-team/src/session.rs b/crates/aionui-team/src/session.rs index eab0b6921..88989c313 100644 --- a/crates/aionui-team/src/session.rs +++ b/crates/aionui-team/src/session.rs @@ -53,7 +53,6 @@ pub struct WakeInput { #[derive(Debug, Clone)] pub struct SpawnAgentRequest { pub name: String, - pub agent_type: Option, pub assistant_id: Option, pub model: Option, } @@ -948,11 +947,10 @@ impl TeamSession { /// Spawn a new teammate at the Lead's request (backing of `team_spawn_agent`). /// - /// Validation chain mirrors the phase1 interface contract: + /// Validation chain mirrors the assistant-first team contract: /// 1. Caller must exist and carry `TeammateRole::Lead`. /// 2. `name` is normalized and must not collide with any live agent. - /// 3. `agent_type` (falling back to the caller's backend when unset) must - /// be in the spawn whitelist. + /// 3. `assistant_id` must be present and resolve to a team-capable backend. /// /// On success, a new conversation is created, the agent slot is persisted /// into the team row, the MCP stdio config is written into the conversation @@ -984,25 +982,12 @@ impl TeamSession { return Err(TeamError::DuplicateAgentName(requested_name)); } - if req.assistant_id.is_none() { - let candidate_backend = req - .agent_type - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(caller.backend.as_str()) - .to_owned(); - - if crate::service::spawn_support::parse_agent_type(&candidate_backend).is_err() { - return Err(TeamError::BackendNotAllowed(candidate_backend)); - } - - if !crate::guide::capability::TEAM_CAPABLE_BACKENDS.contains(&candidate_backend.as_str()) - && self.service.upgrade().is_none() - { - return Err(TeamError::BackendNotAllowed(candidate_backend)); - } - } + let assistant_id = req + .assistant_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| TeamError::InvalidRequest("spawn_agent.assistant_id is required".into()))?; let service = self .service @@ -1014,8 +999,7 @@ impl TeamSession { // identity rather than inheriting the caller backend. let (backend, model) = service .resolve_spawn_backend_and_model( - req.assistant_id.as_deref(), - req.agent_type.as_deref(), + Some(assistant_id), req.model.as_deref(), caller.backend.as_str(), caller.model.as_str(), @@ -1039,7 +1023,7 @@ impl TeamSession { requested_name, backend, model, - req.assistant_id.clone(), + Some(assistant_id.to_owned()), ) .await?; @@ -2303,8 +2287,7 @@ mod tests { fn sample_spawn_req() -> SpawnAgentRequest { SpawnAgentRequest { name: "Helper".into(), - agent_type: None, - assistant_id: None, + assistant_id: Some("word-creator".into()), model: None, } } @@ -2754,11 +2737,10 @@ mod tests { (session, recorder) } - fn spawn_req(agent_type: Option<&str>) -> SpawnAgentRequest { + fn spawn_req(assistant_id: Option<&str>) -> SpawnAgentRequest { SpawnAgentRequest { name: "Helper".into(), - agent_type: agent_type.map(str::to_owned), - assistant_id: None, + assistant_id: assistant_id.map(str::to_owned), model: None, } } @@ -2775,66 +2757,15 @@ mod tests { } #[tokio::test] - async fn spawn_agent_accepts_claude_backend() { + async fn spawn_agent_requires_assistant_identity() { let session = start_session_with_lead_backend("claude").await; - let err = session - .spawn_agent("lead-1", spawn_req(Some("claude"))) - .await - .expect_err("unit test has no service wire; spawn stops at DB step"); - assert_reached_db_step(err); - session.stop(); - } - - #[tokio::test] - async fn spawn_agent_accepts_codex_backend() { - let session = start_session_with_lead_backend("claude").await; - let err = session - .spawn_agent("lead-1", spawn_req(Some("codex"))) - .await - .expect_err("unit test has no service wire; spawn stops at DB step"); - assert_reached_db_step(err); - session.stop(); - } - - #[tokio::test] - async fn spawn_agent_rejects_unknown_backend() { - let session = start_session_with_lead_backend("claude").await; - let err = session - .spawn_agent("lead-1", spawn_req(Some("unknown_backend"))) - .await - .expect_err("unknown backend must be rejected"); - assert!( - matches!(&err, TeamError::BackendNotAllowed(b) if b == "unknown_backend"), - "expected BackendNotAllowed(\"unknown_backend\"), got {err:?}" - ); - session.stop(); - } - - #[tokio::test] - async fn spawn_agent_inherits_caller_backend_when_unspecified() { - // No agent_type on the request -> must fall back to the caller's - // backend ("claude"), which passes the whitelist. - let session = start_session_with_lead_backend("claude").await; - let err = session - .spawn_agent("lead-1", spawn_req(None)) - .await - .expect_err("unit test has no service wire; spawn stops at DB step"); - assert_reached_db_step(err); - session.stop(); - } - - #[tokio::test] - async fn spawn_agent_rejects_when_inherited_backend_not_whitelisted() { - // Caller's backend is "acp" (not whitelisted). With no explicit - // agent_type, the inherited backend must be rejected. - let session = start_session_with_lead_backend("acp").await; let err = session .spawn_agent("lead-1", spawn_req(None)) .await - .expect_err("non-whitelisted inherited backend must be rejected"); + .expect_err("assistant_id must be required"); assert!( - matches!(&err, TeamError::BackendNotAllowed(b) if b == "acp"), - "expected BackendNotAllowed(\"acp\"), got {err:?}" + matches!(&err, TeamError::InvalidRequest(msg) if msg.contains("assistant_id is required")), + "expected InvalidRequest about missing assistant_id, got {err:?}" ); session.stop(); } @@ -2843,7 +2774,7 @@ mod tests { async fn spawn_agent_rejects_non_lead_caller() { let session = start_session_with_lead_backend("claude").await; let err = session - .spawn_agent("worker-1", spawn_req(Some("claude"))) + .spawn_agent("worker-1", spawn_req(Some("word-creator"))) .await .expect_err("non-lead caller must be rejected"); assert!( @@ -2858,7 +2789,7 @@ mod tests { let session = start_session_with_lead_backend("claude").await; // The seeded team already has an agent named "Worker". Case + trim // normalization means " worker " collides. - let mut req = spawn_req(Some("claude")); + let mut req = spawn_req(Some("word-creator")); req.name = " worker ".into(); let err = session .spawn_agent("lead-1", req) @@ -2874,7 +2805,7 @@ mod tests { #[tokio::test] async fn spawn_agent_rejects_empty_name() { let session = start_session_with_lead_backend("claude").await; - let mut req = spawn_req(Some("claude")); + let mut req = spawn_req(Some("word-creator")); req.name = " ".into(); let err = session .spawn_agent("lead-1", req) @@ -2902,7 +2833,7 @@ mod tests { async fn spawn_agent_does_not_emit_before_db_step() { let (session, recorder) = start_session_with_recorder("claude").await; let err = session - .spawn_agent("lead-1", spawn_req(Some("claude"))) + .spawn_agent("lead-1", spawn_req(Some("word-creator"))) .await .expect_err("unit test has no service wire; spawn stops at DB step"); assert_reached_db_step(err); @@ -2918,7 +2849,7 @@ mod tests { async fn spawn_agent_does_not_emit_on_guard_rejection() { let (session, recorder) = start_session_with_recorder("claude").await; let err = session - .spawn_agent("worker-1", spawn_req(Some("claude"))) + .spawn_agent("worker-1", spawn_req(Some("word-creator"))) .await .expect_err("non-lead caller must be rejected"); assert!(matches!(&err, TeamError::LeaderOnly(what) if what == "spawn_agent")); diff --git a/crates/aionui-team/tests/mcp_server_integration.rs b/crates/aionui-team/tests/mcp_server_integration.rs index a32bfca89..3e41c1590 100644 --- a/crates/aionui-team/tests/mcp_server_integration.rs +++ b/crates/aionui-team/tests/mcp_server_integration.rs @@ -416,7 +416,7 @@ async fn sp1_lead_spawn_requires_live_session_service() { &mut stream, 2, "team_spawn_agent", - json!({"name": "Helper", "role": "worker", "backend": "claude"}), + json!({"name": "Helper", "role": "worker", "assistant_id": "word-creator"}), ) .await; @@ -431,7 +431,7 @@ async fn sp1_lead_spawn_requires_live_session_service() { } #[tokio::test] -async fn sp2_non_whitelisted_backend_rejected() { +async fn sp2_legacy_backend_alias_rejected() { let env = setup().await; let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await; @@ -445,9 +445,8 @@ async fn sp2_non_whitelisted_backend_rejected() { assert!(is_error_response(&resp)); let text = extract_text(&resp); - // Without a live TeamSessionService the spawn fails at capability check or service access. assert!( - text.contains("not allowed") || text.contains("not available"), + text.contains("backend is no longer accepted"), "unexpected error: {text}" ); diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index 9f7dafd3d..fa2ffffe0 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -2687,8 +2687,7 @@ async fn spawn_agent_in_session_rejects_without_active_team_run_before_persistin let req = SpawnAgentRequest { name: "Helper".into(), - agent_type: Some("claude".into()), - assistant_id: None, + assistant_id: Some("word-creator".into()), model: Some("claude-sonnet-4".into()), }; From d6dd73f1bb7665f59d897e32afc961a8080506bf Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 17:51:56 +0800 Subject: [PATCH 055/135] refactor(team): emit assistant-native response fields --- crates/aionui-api-types/src/team.rs | 22 ++++++++++++++++++++++ crates/aionui-team/src/types.rs | 2 ++ 2 files changed, 24 insertions(+) diff --git a/crates/aionui-api-types/src/team.rs b/crates/aionui-api-types/src/team.rs index f6e0f093a..520ededdd 100644 --- a/crates/aionui-api-types/src/team.rs +++ b/crates/aionui-api-types/src/team.rs @@ -330,9 +330,13 @@ pub struct TeamChildTurnPayload { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct TeamAgentResponse { pub slot_id: String, + #[serde(default)] + pub assistant_name: String, pub name: String, pub role: String, pub conversation_id: String, + #[serde(default)] + pub assistant_backend: String, pub backend: String, #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, @@ -752,9 +756,11 @@ mod tests { fn serialize_team_agent_response_snake_case() { let agent = TeamAgentResponse { slot_id: "slot-1".into(), + assistant_name: "Lead Agent".into(), name: "Lead Agent".into(), role: "lead".into(), conversation_id: "conv-1".into(), + assistant_backend: "acp".into(), backend: "acp".into(), icon: Some("/api/assets/logos/ai-major/claude.svg".into()), model: "claude".into(), @@ -764,9 +770,11 @@ mod tests { }; let json = serde_json::to_value(&agent).unwrap(); assert_eq!(json["slot_id"], "slot-1"); + assert_eq!(json["assistant_name"], "Lead Agent"); assert_eq!(json["name"], "Lead Agent"); assert_eq!(json["role"], "lead"); assert_eq!(json["conversation_id"], "conv-1"); + assert_eq!(json["assistant_backend"], "acp"); assert_eq!(json["backend"], "acp"); assert_eq!(json["icon"], "/api/assets/logos/ai-major/claude.svg"); assert_eq!(json["model"], "claude"); @@ -780,9 +788,11 @@ mod tests { fn serialize_team_agent_response_optional_fields_omitted() { let agent = TeamAgentResponse { slot_id: "slot-2".into(), + assistant_name: "Worker".into(), name: "Worker".into(), role: "teammate".into(), conversation_id: "conv-2".into(), + assistant_backend: "acp".into(), backend: "acp".into(), icon: None, model: "claude".into(), @@ -804,9 +814,11 @@ mod tests { workspace: "/workspace/team-1".into(), agents: vec![TeamAgentResponse { slot_id: "slot-1".into(), + assistant_name: "Lead".into(), name: "Lead".into(), role: "lead".into(), conversation_id: "conv-1".into(), + assistant_backend: "acp".into(), backend: "acp".into(), icon: Some("/api/assets/logos/ai-major/claude.svg".into()), model: "claude".into(), @@ -866,9 +878,11 @@ mod tests { team_id: "team-1".into(), agent: TeamAgentResponse { slot_id: "slot-3".into(), + assistant_name: "Dynamic Worker".into(), name: "Dynamic Worker".into(), role: "teammate".into(), conversation_id: "conv-3".into(), + assistant_backend: "claude".into(), backend: "claude".into(), icon: Some("/api/assets/logos/ai-major/claude.svg".into()), model: "opus".into(), @@ -915,9 +929,11 @@ mod tests { fn team_agent_response_roundtrip() { let agent = TeamAgentResponse { slot_id: "slot-1".into(), + assistant_name: "Agent".into(), name: "Agent".into(), role: "lead".into(), conversation_id: "conv-1".into(), + assistant_backend: "acp".into(), backend: "acp".into(), icon: Some("/api/assets/logos/ai-major/claude.svg".into()), model: "claude".into(), @@ -939,9 +955,11 @@ mod tests { agents: vec![ TeamAgentResponse { slot_id: "s1".into(), + assistant_name: "Lead".into(), name: "Lead".into(), role: "lead".into(), conversation_id: "c1".into(), + assistant_backend: "acp".into(), backend: "acp".into(), icon: None, model: "claude".into(), @@ -951,9 +969,11 @@ mod tests { }, TeamAgentResponse { slot_id: "s2".into(), + assistant_name: "Worker".into(), name: "Worker".into(), role: "teammate".into(), conversation_id: "c2".into(), + assistant_backend: "acp".into(), backend: "acp".into(), icon: Some("/api/assets/logos/tools/coding/codex.svg".into()), model: "claude".into(), @@ -989,9 +1009,11 @@ mod tests { team_id: "t1".into(), agent: TeamAgentResponse { slot_id: "s3".into(), + assistant_name: "New".into(), name: "New".into(), role: "teammate".into(), conversation_id: "c3".into(), + assistant_backend: "claude".into(), backend: "claude".into(), icon: None, model: "sonnet".into(), diff --git a/crates/aionui-team/src/types.rs b/crates/aionui-team/src/types.rs index de848232b..eba6c8492 100644 --- a/crates/aionui-team/src/types.rs +++ b/crates/aionui-team/src/types.rs @@ -123,9 +123,11 @@ impl TeamAgent { pub fn to_response_with_icon(&self, icon: Option) -> TeamAgentResponse { TeamAgentResponse { slot_id: self.slot_id.clone(), + assistant_name: self.name.clone(), name: self.name.clone(), role: self.role.to_string(), conversation_id: self.conversation_id.clone(), + assistant_backend: self.backend.clone(), backend: self.backend.clone(), icon, model: self.model.clone(), From 6e10e044d706aef92178b5343d130eebb18c44e8 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 18:04:08 +0800 Subject: [PATCH 056/135] refactor(channel): normalize assistant-first platform settings --- crates/aionui-channel/src/channel_settings.rs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/aionui-channel/src/channel_settings.rs b/crates/aionui-channel/src/channel_settings.rs index 214c9e9b9..fc7a7109d 100644 --- a/crates/aionui-channel/src/channel_settings.rs +++ b/crates/aionui-channel/src/channel_settings.rs @@ -162,7 +162,8 @@ impl ChannelSettingsService { for pref in prefs { if pref.key == key_agent { - assistant = parse_channel_assistant_setting(&pref.value); + assistant = + parse_channel_assistant_setting(&pref.value).map(normalize_channel_assistant_setting_for_response); } else if pref.key == key_model { default_model = parse_channel_model_setting(&pref.value); } @@ -819,6 +820,24 @@ mod tests { assert_eq!(setting.name.as_deref(), Some("Codex")); } + #[tokio::test] + async fn get_platform_settings_promotes_legacy_custom_agent_id_in_response() { + let repo = Arc::new(MockPrefRepo::with_data(vec![( + "assistant.telegram.agent", + r#"{"custom_agent_id":"legacy-custom","name":"Codex"}"#, + )])); + let svc = ChannelSettingsService::new(repo); + + let settings = svc.get_platform_settings(PluginType::Telegram).await.unwrap(); + let assistant = settings.assistant.expect("assistant settings"); + + assert_eq!(assistant.assistant_id.as_deref(), Some("legacy-custom")); + assert!(assistant.custom_agent_id.is_none()); + assert!(assistant.backend.is_none()); + assert!(assistant.agent_type.is_none()); + assert_eq!(assistant.name.as_deref(), Some("Codex")); + } + // ── resolved_model_to_provider ──────────────────────────────────── #[test] From bae546814e692296b50833755cdcc42b02988454 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 18:30:22 +0800 Subject: [PATCH 057/135] refactor(conversation): prefer persisted acp session identity --- .../src/session_context.rs | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/aionui-conversation/src/session_context.rs b/crates/aionui-conversation/src/session_context.rs index 0139ec82a..3c38f2fc0 100644 --- a/crates/aionui-conversation/src/session_context.rs +++ b/crates/aionui-conversation/src/session_context.rs @@ -200,15 +200,14 @@ impl<'a> SessionContextBuilder<'a> { config.session_mode = Some(mode.to_owned()); } - self.resolve_acp_identity(row, &mut config, &extra).await?; - let belongs_to_team = team.is_some(); - let session_row = self .acp_session_repo .get(&row.id) .await .map_err(|e| ConversationError::internal(format!("Failed to load acp_session row: {e}")))?; + self.resolve_acp_identity(row, &mut config, &extra, session_row.as_ref()) + .await?; let session_id = session_row.and_then(|row| row.session_id); let session_snapshot = self.load_acp_session_snapshot(row, &config).await?; @@ -226,12 +225,25 @@ impl<'a> SessionContextBuilder<'a> { row: &ConversationRow, config: &mut AcpBuildExtra, extra: &serde_json::Value, + session_row: Option<&aionui_db::models::AcpSessionRow>, ) -> Result<(), ConversationError> { let agent_id = config.agent_id.as_deref().filter(|value| !value.is_empty()); if agent_id.is_some() { return Ok(()); } + if let Some(session_row) = session_row.filter(|row| !row.agent_id.is_empty()) { + debug!( + conversation_id = %row.id, + agent_id = %session_row.agent_id, + backend = %session_row.agent_backend, + "session_context: restored ACP identity from persisted acp_session row" + ); + config.agent_id = Some(session_row.agent_id.clone()); + config.backend = Some(session_row.agent_backend.clone()); + return Ok(()); + } + let backend = config.backend.as_deref().filter(|value| !value.is_empty()); let agent_source = extra .get("agent_source") @@ -660,6 +672,29 @@ mod tests { assert_eq!(acp.config.current_model_id.as_deref(), Some("legacy-model")); } + #[tokio::test] + async fn acp_session_identity_takes_priority_over_legacy_backend_seed() { + let repos = setup().await; + upsert_builtin(&repos, "builtin-claude-test", "claude").await; + upsert_builtin(&repos, "builtin-codex-test", "codex").await; + repos + .acp_session_repo + .create(&CreateAcpSessionParams { + conversation_id: "conv-1", + agent_backend: "codex", + agent_source: "builtin", + agent_id: "builtin-codex-test", + }) + .await + .unwrap(); + let row = row("acp", serde_json::json!({ "backend": "claude" }), None); + + let context = repos.builder().build(&row).await.unwrap(); + let acp = acp_context(context); + assert_eq!(acp.config.agent_id.as_deref(), Some("builtin-codex-test")); + assert_eq!(acp.config.backend.as_deref(), Some("codex")); + } + #[tokio::test] async fn acp_legacy_current_mode_becomes_startup_seed_without_runtime() { let repos = setup().await; From 1e3533987c57cea27b0eee479abd0a8a48205c81 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 18:40:54 +0800 Subject: [PATCH 058/135] refactor(channel): prefer assistant names for conversations --- crates/aionui-channel/src/message_service.rs | 25 +++++-- .../tests/message_service_integration.rs | 68 +++++++++++++++++++ 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/crates/aionui-channel/src/message_service.rs b/crates/aionui-channel/src/message_service.rs index b14131260..584c214f5 100644 --- a/crates/aionui-channel/src/message_service.rs +++ b/crates/aionui-channel/src/message_service.rs @@ -127,7 +127,16 @@ impl ChannelMessageService { let source = platform_to_source(platform); let agent_config = self.settings.get_agent_config(platform).await?; let assistant_setting = self.settings.get_assistant_setting(platform).await?; - let assistant_id = assistant_setting.and_then(|setting| setting.assistant_id); + let assistant_id = assistant_setting + .as_ref() + .and_then(|setting| setting.assistant_id.as_deref()) + .map(ToOwned::to_owned); + let assistant_name = assistant_setting + .as_ref() + .and_then(|setting| setting.name.as_deref()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToOwned::to_owned); let model_config = self.settings.get_model_config(platform).await?; let agent_type = parse_agent_type(&agent_config.agent_type)?; let model = resolved_model_to_provider(model_config.as_ref()); @@ -136,12 +145,14 @@ impl ChannelMessageService { } else { agent_config.backend.as_deref() }); - let name = channel_conversation_name( - platform, - &agent_config.agent_type, - agent_config.backend.as_deref(), - session.chat_id.as_deref(), - ); + let name = assistant_name.unwrap_or_else(|| { + channel_conversation_name( + platform, + &agent_config.agent_type, + agent_config.backend.as_deref(), + session.chat_id.as_deref(), + ) + }); // Top-level `model` is only accepted for aionrs; other types pass via `extra`. let top_level_model = if agent_type == AgentType::Aionrs { diff --git a/crates/aionui-channel/tests/message_service_integration.rs b/crates/aionui-channel/tests/message_service_integration.rs index e9109a504..b54722f08 100644 --- a/crates/aionui-channel/tests/message_service_integration.rs +++ b/crates/aionui-channel/tests/message_service_integration.rs @@ -362,4 +362,72 @@ async fn send_to_agent_persists_assistant_snapshot_for_channel_bound_assistant() assert!(!session_row.agent_id.is_empty()); assert_eq!(snapshot.assistant_key, "bare-claude"); assert_eq!(snapshot.agent_backend, "claude"); + assert_eq!(conversation.name, "Claude"); +} + +#[tokio::test] +async fn send_to_agent_without_assistant_name_falls_back_to_legacy_channel_name() { + let db = init_database_memory().await.unwrap(); + let pool = db.pool().clone(); + + let task_manager: Arc = Arc::new(RecordingTaskManager::new()); + let conversation_repo = Arc::new(SqliteConversationRepository::new(pool.clone())); + let conversation_repo_trait: Arc = conversation_repo.clone(); + let acp_session_repo = Arc::new(SqliteAcpSessionRepository::new(pool.clone())); + let conversation_svc = Arc::new(ConversationService::new( + std::env::temp_dir(), + Arc::new(TestBroadcaster::new()), + Arc::new(NoopSkillResolver), + Arc::clone(&task_manager), + conversation_repo_trait, + Arc::new(SqliteAgentMetadataRepository::new(pool.clone())), + acp_session_repo, + )); + + let pref_repo = Arc::new(SqliteClientPreferenceRepository::new(pool.clone())); + let definition_repo = Arc::new(SqliteAssistantDefinitionRepository::new(pool.clone())); + let overlay_repo = Arc::new(SqliteAssistantOverlayRepository::new(pool.clone())); + let assistant_preference_repo = Arc::new(SqliteAssistantPreferenceRepository::new(pool.clone())); + conversation_svc.with_assistant_definition_repo(definition_repo.clone()); + conversation_svc.with_assistant_state_repo(overlay_repo.clone()); + conversation_svc.with_assistant_preference_repo(assistant_preference_repo); + definition_repo + .upsert(&bare_assistant_definition_params( + "asstdef-channel-codex", + "bare-codex", + "codex", + )) + .await + .unwrap(); + pref_repo + .upsert_batch(&[("assistant.telegram.agent", r#"{"assistant_id":"bare-codex"}"#)]) + .await + .unwrap(); + + let settings = Arc::new(ChannelSettingsService::new(pref_repo).with_assistant_repos(definition_repo, overlay_repo)); + let message_svc = ChannelMessageService::new( + conversation_svc, + Arc::clone(&task_manager), + settings, + "system_default_user".to_owned(), + ); + + let session = AssistantSessionRow { + id: "session-assisted-fallback-name".to_owned(), + user_id: "channel-user-2".to_owned(), + agent_type: "aionrs".to_owned(), + conversation_id: None, + workspace: None, + chat_id: Some("7088048016".to_owned()), + created_at: 1, + last_activity: 1, + }; + + let result = message_svc + .send_to_agent(&session, "hello", PluginType::Telegram) + .await + .unwrap(); + + let conversation = conversation_repo.get(&result.conversation_id).await.unwrap().unwrap(); + assert_eq!(conversation.name, "tg-acp-codex-70880480"); } From 6b01b29fb417ac8c74b2a67e200964441766e169 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 19:04:46 +0800 Subject: [PATCH 059/135] fix(conversation): persist resolved agent identity from snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-2 frontends only send `assistant.id` and rely on the backend to resolve runtime identity from the assistant snapshot. But the create handler stopped writing `backend`, `agent_id`, and `agent_source` into `extra`, while the rest of the runtime — ACP factory, ACP session creation, the session-context fallback chain — still expects them. The result: creating an ACP conversation succeeded, but the next warmup/messages call returned \`ACP agent requires either agent_id or backend in extra\`. Re-derive these three fields from the resolved assistant snapshot at create time so the legacy contract is preserved without reintroducing a frontend-driven write path. The values are owned by the assistant, not the request, which is exactly what the design called for. --- crates/aionui-conversation/src/service.rs | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 4e83a7abb..7e915f5cc 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -748,6 +748,36 @@ impl ConversationService { if let Some(snapshot) = assistant_snapshot.as_ref() && let Some(obj) = extra.as_object_mut() { + // Phase 2 frontends only send `assistant.id` and rely on the + // backend to resolve runtime identity from the snapshot. The + // legacy `extra.{backend, agent_id, agent_source}` triple is + // still consumed by ACP factory paths (`factory/acp.rs:34`), + // ACP session creation (`create_acp_session_row`), the + // session-context fallback chain, and several downstream + // helpers. Persisting them here keeps one source of truth — + // the assistant — while preserving the contract those + // downstreams already depend on. + if !snapshot.agent_backend.is_empty() && !obj.contains_key("backend") { + obj.insert( + "backend".to_owned(), + serde_json::Value::String(snapshot.agent_backend.clone()), + ); + } + if let Some(agent_id) = snapshot.agent_id.as_ref() + && !agent_id.is_empty() + && !obj.contains_key("agent_id") + { + obj.insert("agent_id".to_owned(), serde_json::Value::String(agent_id.clone())); + } + if let Some(agent_source) = snapshot.agent_source.as_ref() + && !agent_source.is_empty() + && !obj.contains_key("agent_source") + { + obj.insert( + "agent_source".to_owned(), + serde_json::Value::String(agent_source.clone()), + ); + } if let Some(model_id) = snapshot.resolved_defaults.model.as_ref() && !obj.contains_key("current_model_id") { From 826a96e75ad05684dccb19d4672e7c6929e7e93a Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 19:25:57 +0800 Subject: [PATCH 060/135] refactor(team): prefer assistant ids in command shims --- .../aionui-app/src/commands/cmd_team_guide.rs | 23 +++++++--------- .../aionui-app/src/commands/cmd_team_stdio.rs | 27 +++++++------------ 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/crates/aionui-app/src/commands/cmd_team_guide.rs b/crates/aionui-app/src/commands/cmd_team_guide.rs index 90f699320..5020cd9f5 100644 --- a/crates/aionui-app/src/commands/cmd_team_guide.rs +++ b/crates/aionui-app/src/commands/cmd_team_guide.rs @@ -153,12 +153,9 @@ struct SendMessageParams { struct SpawnAgentParams { /// Name for the new teammate agent. name: String, - /// AI backend type: "claude" or "codex". Default when omitted. - #[serde(default)] - agent_type: Option, - /// Preset assistant identifier. - #[serde(default)] - custom_agent_id: Option, + /// Assistant identifier from the available assistants catalog. + #[serde(default, alias = "custom_agent_id")] + assistant_id: Option, /// Model override for the new agent. #[serde(default)] model: Option, @@ -223,8 +220,9 @@ struct TeamListModelsParams { #[derive(Deserialize, schemars::JsonSchema)] struct DescribeAssistantParams { - /// Preset assistant identifier to look up. - custom_agent_id: String, + /// Assistant identifier to look up. + #[serde(alias = "custom_agent_id")] + assistant_id: String, /// Locale for the description (e.g. "en", "zh"). Default when omitted. #[serde(default)] locale: Option, @@ -279,15 +277,14 @@ impl GuideServer { #[tool( name = "team_spawn_agent", - description = "Create a new teammate agent to join the team. Leader only." + description = "Create a new teammate agent from an assistant in the available assistants catalog. Leader only." )] async fn team_spawn_agent(&self, Parameters(params): Parameters) -> CallToolResult { self.forward_tool( "team_spawn_agent", &serde_json::json!({ "name": params.name, - "agent_type": params.agent_type, - "custom_agent_id": params.custom_agent_id, + "assistant_id": params.assistant_id, "model": params.model, }), ) @@ -382,13 +379,13 @@ impl GuideServer { #[tool( name = "team_describe_assistant", - description = "Get detailed information about a preset assistant before spawning." + description = "Get detailed information about an assistant before spawning it as a teammate." )] async fn team_describe_assistant(&self, Parameters(params): Parameters) -> CallToolResult { self.forward_tool( "team_describe_assistant", &serde_json::json!({ - "custom_agent_id": params.custom_agent_id, + "assistant_id": params.assistant_id, "locale": params.locale, }), ) diff --git a/crates/aionui-app/src/commands/cmd_team_stdio.rs b/crates/aionui-app/src/commands/cmd_team_stdio.rs index 05c66ff29..8c656c527 100644 --- a/crates/aionui-app/src/commands/cmd_team_stdio.rs +++ b/crates/aionui-app/src/commands/cmd_team_stdio.rs @@ -129,18 +129,12 @@ struct SendMessageParams { struct SpawnAgentParams { /// Agent display name. name: String, - /// AI backend type: "claude" or "codex". Default when omitted. - #[serde(default)] - agent_type: Option, /// Model override for the new agent. #[serde(default)] model: Option, - /// Preset assistant identifier. - #[serde(default)] - custom_agent_id: Option, - /// Legacy backend field (prefer agent_type). - #[serde(default)] - backend: Option, + /// Assistant identifier from the available assistants catalog. + #[serde(default, alias = "custom_agent_id")] + assistant_id: Option, /// Agent role (default: "teammate"). #[serde(default)] role: Option, @@ -205,8 +199,9 @@ struct ListModelsParams { #[derive(Deserialize, schemars::JsonSchema)] struct DescribeAssistantParams { - /// The preset assistant ID from the "Available Preset Assistants" catalog. - custom_agent_id: String, + /// The assistant ID from the "Available Assistants" catalog. + #[serde(alias = "custom_agent_id")] + assistant_id: String, /// Locale for the description (e.g. "en", "zh"). Default when omitted. #[serde(default)] locale: Option, @@ -232,17 +227,15 @@ impl TeamStdioServer { #[tool( name = "team_spawn_agent", - description = "Create a new teammate agent to join the team.\n\nUse this only when one of the following is true:\n- The user explicitly approved the proposed teammate lineup in a previous message\n- The user explicitly instructed you to create a specific teammate immediately\n\nBefore calling this tool in the normal planning flow:\n- Start with one short sentence explaining why additional teammates would help\n- Tell the user which teammate(s) you recommend\n- Present the proposal as a table with: name, responsibility, recommended agent type/backend, and recommended model\n- Include each teammate's responsibility, recommended agent type/backend, and model\n- Ask whether to create them as proposed or change any names, responsibilities, or agent types\n- In that approval question, remind the user that they can later ask you to replace or adjust any teammate if the lineup is not working well\n- Do NOT call this tool in that same turn; wait for explicit approval in a later user message\n\nWhen calling this tool, provide the model parameter if a specific model was recommended and approved.\n\nThe new agent will be created and added to the team. You can then assign tasks and send messages to it." + description = "Create a new teammate agent to join the team.\n\nUse this only when one of the following is true:\n- The user explicitly approved the proposed teammate lineup in a previous message\n- The user explicitly instructed you to create a specific teammate immediately\n\nBefore calling this tool in the normal planning flow:\n- Start with one short sentence explaining why additional teammates would help\n- Tell the user which teammate(s) you recommend\n- Present the proposal as a table with: name, responsibility, recommended assistant, and recommended model\n- Include each teammate's responsibility, recommended assistant, and model\n- Ask whether to create them as proposed or change any names, responsibilities, or assistant choices\n- In that approval question, remind the user that they can later ask you to replace or adjust any teammate if the lineup is not working well\n- Do NOT call this tool in that same turn; wait for explicit approval in a later user message\n\nWhen calling this tool, provide the assistant_id parameter and a model only if a specific model was recommended and approved.\n\nThe new agent will be created and added to the team. You can then assign tasks and send messages to it." )] async fn spawn_agent(&self, Parameters(params): Parameters) -> CallToolResult { self.forward_to_tcp( "team_spawn_agent", &serde_json::json!({ "name": params.name, - "agent_type": params.agent_type, "model": params.model, - "custom_agent_id": params.custom_agent_id, - "backend": params.backend, + "assistant_id": params.assistant_id, "role": params.role, }), ) @@ -329,12 +322,12 @@ impl TeamStdioServer { #[tool( name = "team_describe_assistant", - description = "Get detailed information about a preset assistant before spawning it as a teammate.\n\nReturns the preset's full description, enabled skills, and example tasks so you can\njudge whether it fits the user's request. Use this when two or more presets look\nrelevant from the one-line catalog in your system prompt.\n\nOnly works on preset assistants listed in \"Available Preset Assistants for Spawning\".\nAfter confirming a match, call team_spawn_agent with the same custom_agent_id." + description = "Get detailed information about an assistant before spawning it as a teammate.\n\nReturns the assistant's full description, enabled skills, and example tasks so you can\njudge whether it fits the user's request. Use this when two or more assistants look\nrelevant from the one-line catalog in your system prompt.\n\nOnly works on assistants listed in \"Available Assistants for Spawning\".\nAfter confirming a match, call team_spawn_agent with the same assistant_id." )] async fn describe_assistant(&self, Parameters(params): Parameters) -> CallToolResult { self.forward_to_tcp( "team_describe_assistant", - &serde_json::json!({ "custom_agent_id": params.custom_agent_id, "locale": params.locale }), + &serde_json::json!({ "assistant_id": params.assistant_id, "locale": params.locale }), ) .await } From 224b6fb5811d2f8fddaf7c84815f93bd635a844e Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 19:49:39 +0800 Subject: [PATCH 061/135] fix(conversation): prefer snapshot runtime identity --- crates/aionui-app/tests/conversation_e2e.rs | 4 +- crates/aionui-conversation/src/service.rs | 47 ++++--- .../aionui-conversation/src/service_test.rs | 128 +++++++++++++++++- 3 files changed, 154 insertions(+), 25 deletions(-) diff --git a/crates/aionui-app/tests/conversation_e2e.rs b/crates/aionui-app/tests/conversation_e2e.rs index 3c0d8ac32..990601bec 100644 --- a/crates/aionui-app/tests/conversation_e2e.rs +++ b/crates/aionui-app/tests/conversation_e2e.rs @@ -234,7 +234,9 @@ async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { let json = body_json(resp).await; let data = &json["data"]; - assert_eq!(data["extra"]["assistant_id"], "u1"); + assert_eq!(data["assistant"]["id"], "u1"); + assert_eq!(data["assistant"]["backend"], "codex"); + assert!(data["extra"].get("assistant_id").is_none()); assert!(data["extra"].get("preset_assistant_id").is_none()); assert!(data["extra"].get("preset_context").is_none()); assert!(data["extra"].get("preset_rules").is_none()); diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 7e915f5cc..92b7167be 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -708,12 +708,13 @@ impl ConversationService { .as_ref() .map(|snapshot| snapshot.agent_backend.clone()) .filter(|backend| !backend.is_empty()); - let effective_backend = extra - .get("backend") - .and_then(|v| v.as_str()) - .filter(|backend| !backend.is_empty()) - .map(str::to_owned) - .or(assistant_backend); + let effective_backend = assistant_backend.or_else(|| { + extra + .get("backend") + .and_then(|v| v.as_str()) + .filter(|backend| !backend.is_empty()) + .map(str::to_owned) + }); let auto_provisioned_workspace = if user_supplied_workspace.is_none() { // Per-conversation temp workspaces live under @@ -757,7 +758,7 @@ impl ConversationService { // helpers. Persisting them here keeps one source of truth — // the assistant — while preserving the contract those // downstreams already depend on. - if !snapshot.agent_backend.is_empty() && !obj.contains_key("backend") { + if !snapshot.agent_backend.is_empty() { obj.insert( "backend".to_owned(), serde_json::Value::String(snapshot.agent_backend.clone()), @@ -765,18 +766,20 @@ impl ConversationService { } if let Some(agent_id) = snapshot.agent_id.as_ref() && !agent_id.is_empty() - && !obj.contains_key("agent_id") { obj.insert("agent_id".to_owned(), serde_json::Value::String(agent_id.clone())); + } else { + obj.remove("agent_id"); } if let Some(agent_source) = snapshot.agent_source.as_ref() && !agent_source.is_empty() - && !obj.contains_key("agent_source") { obj.insert( "agent_source".to_owned(), serde_json::Value::String(agent_source.clone()), ); + } else { + obj.remove("agent_source"); } if let Some(model_id) = snapshot.resolved_defaults.model.as_ref() && !obj.contains_key("current_model_id") @@ -1104,16 +1107,20 @@ impl ConversationService { // frontend always posts agent_id for picked rows, but older // payloads may only carry `backend`, so we resolve defensively. let agent_id_from_extra = extra.get("agent_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); - let backend = extra - .get("backend") - .and_then(|v| v.as_str()) + let backend = assistant_snapshot + .map(|snapshot| snapshot.agent_backend.as_str()) .filter(|value| !value.is_empty()) - .or_else(|| assistant_snapshot.map(|snapshot| snapshot.agent_backend.as_str())) + .or_else(|| { + extra + .get("backend") + .and_then(|v| v.as_str()) + .filter(|value| !value.is_empty()) + }) .unwrap_or_default(); - let agent_source = extra - .get("agent_source") - .and_then(|v| v.as_str()) - .or_else(|| assistant_snapshot.and_then(|snapshot| snapshot.agent_source.as_deref())) + let agent_source = assistant_snapshot + .and_then(|snapshot| snapshot.agent_source.as_deref()) + .filter(|value| !value.is_empty()) + .or_else(|| extra.get("agent_source").and_then(|v| v.as_str())) .unwrap_or("builtin"); // Fallback: older clients (electron main, legacy webhooks) only @@ -1122,8 +1129,10 @@ impl ConversationService { // reference. Non-builtin agents must provide `agent_id` // explicitly — custom/extension rows have no unique lookup key // from `(backend, agent_source)` alone. - let resolved_agent_id = match agent_id_from_extra - .or_else(|| assistant_snapshot.and_then(|snapshot| snapshot.agent_id.as_deref())) + let resolved_agent_id = match assistant_snapshot + .and_then(|snapshot| snapshot.agent_id.as_deref()) + .filter(|id| !id.is_empty()) + .or(agent_id_from_extra) { Some(id) => id.to_owned(), None if !backend.is_empty() && agent_source == "builtin" => self diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index dea82a69c..62a85e656 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -624,28 +624,47 @@ struct RuntimeStateSaveCall { #[derive(Default)] struct StubAcpSessionRepo { + create_calls: Mutex>, runtime_state_saves: Mutex>, } impl StubAcpSessionRepo { + fn create_calls(&self) -> Vec { + self.create_calls.lock().unwrap().clone() + } + fn runtime_state_saves(&self) -> Vec { self.runtime_state_saves.lock().unwrap().clone() } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct CreateAcpSessionCall { + conversation_id: String, + agent_backend: String, + agent_source: String, + agent_id: String, +} + #[async_trait::async_trait] impl IAcpSessionRepository for StubAcpSessionRepo { async fn get(&self, _conversation_id: &str) -> Result, DbError> { Ok(None) } - async fn create(&self, _params: &CreateAcpSessionParams<'_>) -> Result { + async fn create(&self, params: &CreateAcpSessionParams<'_>) -> Result { + self.create_calls.lock().unwrap().push(CreateAcpSessionCall { + conversation_id: params.conversation_id.to_owned(), + agent_backend: params.agent_backend.to_owned(), + agent_source: params.agent_source.to_owned(), + agent_id: params.agent_id.to_owned(), + }); // Return a synthetic row so `ConversationService::create` can // succeed for ACP conversations in unit tests. Ok(AcpSessionRow { - conversation_id: "stub".into(), - agent_backend: "stub".into(), - agent_source: "stub".into(), - agent_id: "stub".into(), + conversation_id: params.conversation_id.to_owned(), + agent_backend: params.agent_backend.to_owned(), + agent_source: params.agent_source.to_owned(), + agent_id: params.agent_id.to_owned(), session_id: None, session_status: "idle".into(), session_config: "{}".into(), @@ -793,6 +812,43 @@ async fn make_service_with_assistant_support( (svc, broadcaster, repo, definition_repo, state_repo, preference_repo) } +async fn make_service_with_assistant_support_and_acp_session_repo( + skill_resolver: Arc, + dispatcher: Arc, + acp_session_repo: Arc, +) -> ( + ConversationService, + Arc, + Arc, + Arc, + Arc, + Arc, + Arc, +) { + let (svc, broadcaster, repo, _task_mgr) = + make_service_with_resolver_and_acp_session_repo(skill_resolver, acp_session_repo.clone()); + let db = init_database_memory().await.unwrap(); + let definition_repo = Arc::new(SqliteAssistantDefinitionRepository::new(db.pool().clone())); + let state_repo = Arc::new(SqliteAssistantOverlayRepository::new(db.pool().clone())); + let preference_repo: Arc = + Arc::new(SqliteAssistantPreferenceRepository::new(db.pool().clone())); + + svc.with_assistant_definition_repo(definition_repo.clone()); + svc.with_assistant_state_repo(state_repo.clone()); + svc.with_assistant_preference_repo(preference_repo.clone()); + svc.with_assistant_dispatcher(dispatcher); + + ( + svc, + broadcaster, + repo, + definition_repo, + state_repo, + preference_repo, + acp_session_repo, + ) +} + fn make_create_req() -> CreateConversationRequest { let workspace = ensure_test_workspace_path(); serde_json::from_value(json!({ @@ -4256,6 +4312,68 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { ); } +#[tokio::test] +async fn create_prefers_snapshot_runtime_identity_over_legacy_extra_identity() { + let resolver = Arc::new(FixedSkillResolver { names: vec![] }); + let dispatcher = Arc::new(StaticAssistantDispatcher { + rules: std::collections::HashMap::new(), + }); + let acp_repo = Arc::new(StubAcpSessionRepo::default()); + let (svc, _broadcaster, _repo, definition_repo, overlay_repo, _preference_repo, acp_repo) = + make_service_with_assistant_support_and_acp_session_repo(resolver, dispatcher, acp_repo).await; + + upsert_test_assistant_definition( + &definition_repo, + "asstdef_snapshot_identity", + "preset-snapshot-identity", + "codex", + "auto", + "auto", + ) + .await; + overlay_repo + .upsert(&UpsertAssistantOverlayParams { + definition_id: "asstdef_snapshot_identity", + enabled: true, + sort_order: 0, + agent_backend_override: None, + last_used_at: None, + }) + .await + .unwrap(); + + let workspace = ensure_test_workspace_path(); + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "assistant": { + "id": "preset-snapshot-identity", + "locale": "en-US" + }, + "extra": { + "workspace": workspace, + "backend": "claude", + "agent_source": "custom", + "agent_id": "legacy-custom-agent" + }, + })) + .unwrap(); + + let resp = svc.create("user-1", req).await.unwrap(); + + assert_eq!( + resp.assistant.as_ref().map(|assistant| assistant.backend.as_str()), + Some("codex") + ); + assert_eq!(resp.extra["backend"], json!("codex")); + assert!(resp.extra.get("agent_id").is_none()); + + let create_calls = acp_repo.create_calls(); + assert_eq!(create_calls.len(), 1); + assert_eq!(create_calls[0].agent_backend, "codex"); + assert_eq!(create_calls[0].agent_source, "builtin"); + assert_ne!(create_calls[0].agent_id, "legacy-custom-agent"); +} + #[tokio::test] async fn create_does_not_overwrite_preferences_for_fixed_skills_and_mcps() { let resolver = Arc::new(FixedSkillResolver { From 1efb084e9bb6279afe10569e752009bb20ca71f0 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 19:58:46 +0800 Subject: [PATCH 062/135] refactor(cron): strip legacy ids from assistant responses --- crates/aionui-cron/src/types.rs | 74 +++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/crates/aionui-cron/src/types.rs b/crates/aionui-cron/src/types.rs index 61b55c3e0..d39bf7f3c 100644 --- a/crates/aionui-cron/src/types.rs +++ b/crates/aionui-cron/src/types.rs @@ -331,18 +331,29 @@ pub fn cron_job_to_response(job: &CronJob) -> CronJobResponse { }, }; - let agent_config_dto = job.agent_config.as_ref().map(|c| CronAgentConfigDto { - backend: c.backend.clone(), - name: c.name.clone(), - cli_path: c.cli_path.clone(), - is_preset: c.is_preset, - assistant_id: c.assistant_id.clone(), - custom_agent_id: c.custom_agent_id.clone(), - preset_agent_type: c.preset_agent_type.clone(), - mode: c.mode.clone(), - model_id: c.model_id.clone(), - config_options: c.config_options.clone(), - workspace: c.workspace.clone(), + let agent_config_dto = job.agent_config.as_ref().map(|c| { + let assistant_backed = c.assistant_id.is_some(); + CronAgentConfigDto { + backend: c.backend.clone(), + name: c.name.clone(), + cli_path: if assistant_backed { None } else { c.cli_path.clone() }, + is_preset: if assistant_backed { None } else { c.is_preset }, + assistant_id: c.assistant_id.clone(), + custom_agent_id: if assistant_backed { + None + } else { + c.custom_agent_id.clone() + }, + preset_agent_type: if assistant_backed { + None + } else { + c.preset_agent_type.clone() + }, + mode: c.mode.clone(), + model_id: c.model_id.clone(), + config_options: c.config_options.clone(), + workspace: c.workspace.clone(), + } }); CronJobResponse { @@ -815,6 +826,45 @@ mod tests { assert!(resp.metadata.agent_config.is_none()); } + #[test] + fn domain_to_dto_strips_legacy_agent_fields_for_assistant_backed_jobs() { + let job = CronJob { + agent_config: Some(CronAgentConfig { + backend: "codex".into(), + name: "文件规划助手".into(), + cli_path: Some("/tmp/codex".into()), + is_preset: Some(true), + assistant_id: Some("assistant-1".into()), + custom_agent_id: Some("legacy-assistant".into()), + preset_agent_type: Some("codex".into()), + mode: Some("full-access".into()), + model_id: Some("gpt-5-codex".into()), + config_options: Some(HashMap::from([("sandbox_mode".into(), "workspace-write".into())])), + workspace: Some("/tmp/project".into()), + }), + ..sample_job() + }; + + let resp = cron_job_to_response(&job); + let config = resp.metadata.agent_config.expect("assistant config should be present"); + + assert_eq!(config.assistant_id.as_deref(), Some("assistant-1")); + assert!(config.cli_path.is_none()); + assert!(config.is_preset.is_none()); + assert!(config.custom_agent_id.is_none()); + assert!(config.preset_agent_type.is_none()); + assert_eq!(config.mode.as_deref(), Some("full-access")); + assert_eq!(config.model_id.as_deref(), Some("gpt-5-codex")); + assert_eq!( + config + .config_options + .as_ref() + .and_then(|options| options.get("sandbox_mode")), + Some(&"workspace-write".to_owned()) + ); + assert_eq!(config.workspace.as_deref(), Some("/tmp/project")); + } + #[test] fn domain_to_dto_new_conversation_mode() { let job = CronJob { From 764a64bf5112393576eab5b927995ab7341bd3be Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 20:08:55 +0800 Subject: [PATCH 063/135] refactor(cron): prefer runtime backend over stale extra --- crates/aionui-cron/src/service.rs | 10 ++- .../aionui-cron/tests/service_integration.rs | 61 +++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index 565d52f5e..be8cbcf47 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -1090,12 +1090,10 @@ fn build_agent_config_from_conversation( .or_else(|| get_string(&extra, &["backend"])) .unwrap_or_else(|| "aionrs".to_owned()) } else { - get_string(&extra, &["backend"]) - .or_else(|| { - model - .map(|value| value.provider_id.clone()) - .filter(|value| !value.is_empty()) - }) + model + .map(|value| value.provider_id.clone()) + .filter(|value| !value.is_empty()) + .or_else(|| get_string(&extra, &["backend"])) .unwrap_or_else(|| row.r#type.clone()) }; diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index 0079537c4..79a77e5a6 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -295,6 +295,36 @@ impl IConversationRepository for StubConvRepo { created_at: 1000, updated_at: 1000, } + } else if id == "conv_mode_stale_backend" { + aionui_db::models::ConversationRow { + id: id.into(), + user_id: "u1".into(), + name: "Gemini Stale Backend Chat".into(), + r#type: "acp".into(), + model: Some( + serde_json::json!({ + "provider_id": "gemini", + "model": "gemini-2.5-pro", + "use_model": "gemini-2.5-pro" + }) + .to_string(), + ), + status: Some("active".into()), + source: None, + channel_chat_id: None, + extra: serde_json::json!({ + "backend": "claude", + "agent_name": "Gemini", + "workspace": ensure_named_workspace_path("aionui-cron-service-stale-backend-workspace"), + "session_mode": "yolo", + "current_model_id": "gemini-2.5-pro" + }) + .to_string(), + pinned: false, + pinned_at: None, + created_at: 1000, + updated_at: 1000, + } } else if id == "conv_mode_aionrs" { aionui_db::models::ConversationRow { id: id.into(), @@ -1616,6 +1646,37 @@ async fn icron_service_create_job_inherits_conversation_mode_and_backend() { ); } +#[tokio::test] +async fn icron_service_create_job_prefers_model_provider_over_stale_extra_backend() { + let (svc, _, _) = setup().await; + + use aionui_conversation::response_middleware::ICronService; + + let params = CronCreateParams { + name: "Agent Job".into(), + schedule: "0 */10 * * * *".into(), + schedule_description: "every 10 min".into(), + message: "do agent work".into(), + }; + + let result = ICronService::create_job(&svc, "user_1", "conv_mode_stale_backend", ¶ms).await; + assert!(result.success); + + let jobs = svc + .list_jobs(&ListCronJobsQuery { + conversation_id: Some("conv_mode_stale_backend".into()), + }) + .await + .unwrap(); + assert_eq!(jobs.len(), 1); + + let job = &jobs[0]; + let config = job.agent_config.as_ref().expect("agent config should be copied"); + assert_eq!(job.agent_type, "acp"); + assert_eq!(config.backend, "gemini"); + assert_eq!(config.model_id.as_deref(), Some("gemini-2.5-pro")); +} + #[tokio::test] async fn icron_service_create_job_forces_full_auto_mode_for_generated_crons() { let (svc, _, _) = setup().await; From 466a9384a2269a6f7db1bd364890482fe661b410 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 20:51:12 +0800 Subject: [PATCH 064/135] feat(agent): expose backend logo catalog endpoint Add GET /api/agents/logos returning a backend -> logo URL map projected from agent_metadata, including disabled and missing rows so business surfaces can resolve agent logos from a backend identifier without owning a hardcoded path map. --- crates/aionui-ai-agent/src/routes/agent.rs | 16 ++- crates/aionui-ai-agent/src/services/agent.rs | 28 +++++- .../aionui-api-types/src/agent_discovery.rs | 10 ++ crates/aionui-api-types/src/lib.rs | 4 +- .../aionui-app/tests/agent_integration_e2e.rs | 98 +++++++++++++++++++ 5 files changed, 150 insertions(+), 6 deletions(-) diff --git a/crates/aionui-ai-agent/src/routes/agent.rs b/crates/aionui-ai-agent/src/routes/agent.rs index ebc9b4f6b..fe6167696 100644 --- a/crates/aionui-ai-agent/src/routes/agent.rs +++ b/crates/aionui-ai-agent/src/routes/agent.rs @@ -14,9 +14,9 @@ use axum::extract::{Extension, Json, Path, State}; use axum::routing::{get, patch, post, put}; use aionui_api_types::{ - AgentManagementRow, AgentMetadata, ApiResponse, CustomAgentUpsertRequest, DeleteCustomAgentResponse, - ProviderHealthCheckRequest, ProviderHealthCheckResponse, SetEnabledRequest, TryConnectCustomAgentRequest, - TryConnectCustomAgentResponse, + AgentLogoEntry, AgentManagementRow, AgentMetadata, ApiResponse, CustomAgentUpsertRequest, + DeleteCustomAgentResponse, ProviderHealthCheckRequest, ProviderHealthCheckResponse, SetEnabledRequest, + TryConnectCustomAgentRequest, TryConnectCustomAgentResponse, }; use aionui_auth::CurrentUser; use aionui_common::ApiError; @@ -27,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)) .route("/api/agents/{id}/health-check", post(health_check_by_id)) @@ -56,6 +57,15 @@ async fn refresh_agents( ))) } +async fn list_agent_logos( + State(state): State, + Extension(_user): Extension, +) -> Result>>, ApiError> { + Ok(Json(ApiResponse::ok( + state.service.list_agent_logos().await.map_err(agent_error_to_api_error)?, + ))) +} + async fn list_management_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 32c5ba704..3962ccb21 100644 --- a/crates/aionui-ai-agent/src/services/agent.rs +++ b/crates/aionui-ai-agent/src/services/agent.rs @@ -15,7 +15,9 @@ use std::path::PathBuf; use std::sync::Arc; -use aionui_api_types::{AgentManagementRow, AgentMetadata, ProviderHealthCheckRequest, ProviderHealthCheckResponse}; +use aionui_api_types::{ + AgentLogoEntry, AgentManagementRow, AgentMetadata, ProviderHealthCheckRequest, ProviderHealthCheckResponse, +}; use aionui_db::IProviderRepository; use aionui_realtime::EventBroadcaster; @@ -103,6 +105,30 @@ impl AgentService { Ok(self.availability.list_management_rows().await) } + /// Backend → logo URL catalog for business surfaces. + /// + /// Business pages (guid, team, cron, conversation lists) must render + /// an agent logo from a backend identifier alone, without owning a + /// hardcoded path map. This projects every known agent row — including + /// user-disabled or currently-missing ones, so historical conversations + /// still resolve a logo — down to its `backend` and stored `icon` URL. + pub async fn list_agent_logos(&self) -> Result, AgentError> { + let mut seen = std::collections::HashSet::new(); + let mut entries = Vec::new(); + for agent in self.registry.list_all_including_hidden().await { + let (Some(backend), Some(logo)) = (agent.backend, agent.icon) else { + continue; + }; + if backend.is_empty() || logo.is_empty() { + continue; + } + if seen.insert(backend.clone()) { + entries.push(AgentLogoEntry { backend, logo }); + } + } + Ok(entries) + } + pub async fn health_check_agent_by_id(&self, id: &str) -> Result { self.availability.run_manual_health_check(id).await } diff --git a/crates/aionui-api-types/src/agent_discovery.rs b/crates/aionui-api-types/src/agent_discovery.rs index 9d9165d51..028084065 100644 --- a/crates/aionui-api-types/src/agent_discovery.rs +++ b/crates/aionui-api-types/src/agent_discovery.rs @@ -130,6 +130,16 @@ pub enum AgentSnapshotCheckKind { Session, } +/// A single `backend → logo URL` pair in the agent logo catalog. +/// +/// Returned by `GET /api/agents/logos` so business surfaces can resolve +/// an agent logo from a backend identifier without owning a path map. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentLogoEntry { + pub backend: String, + pub logo: String, +} + /// The unified, decoded view of an `agent_metadata` row. /// /// Also the API response shape: `/api/agents` returns a list of these diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index 6b578e66a..508204378 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -43,8 +43,8 @@ pub use agent_build_extra::{ SlashCommandCompletionBehavior, SlashCommandItem, }; pub use agent_discovery::{ - AgentEnvEntry, AgentHandshake, AgentManagementRow, AgentManagementStatus, AgentMetadata, AgentSnapshotCheckKind, - AgentSnapshotCheckStatus, AgentSource, AgentSourceInfo, BehaviorPolicy, + AgentEnvEntry, AgentHandshake, AgentLogoEntry, AgentManagementRow, AgentManagementStatus, AgentMetadata, + AgentSnapshotCheckKind, AgentSnapshotCheckStatus, AgentSource, AgentSourceInfo, BehaviorPolicy, }; pub use agent_error::{ AgentErrorCode, AgentErrorOwnership, AgentErrorResolution, AgentErrorResolutionKind, AgentErrorResolutionTarget, diff --git a/crates/aionui-app/tests/agent_integration_e2e.rs b/crates/aionui-app/tests/agent_integration_e2e.rs index 87808b943..23dab3e2f 100644 --- a/crates/aionui-app/tests/agent_integration_e2e.rs +++ b/crates/aionui-app/tests/agent_integration_e2e.rs @@ -328,6 +328,104 @@ async fn agents_endpoint_handles_openclaw_as_acp_backend() { ); } +#[tokio::test] +async fn agent_logos_endpoint_returns_backend_to_logo_catalog() { + let (mut app, services, _mock_tm) = build_app_with_mock_tasks().await; + let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "Pass123!").await; + + let req = get_with_token("/api/agents/logos", &token); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = body_json(resp).await; + let entries = body["data"].as_array().expect("data should be array"); + + let logo_for = |backend: &str| -> Option { + entries + .iter() + .find(|entry| entry["backend"].as_str() == Some(backend)) + .and_then(|entry| entry["logo"].as_str()) + .map(str::to_owned) + }; + + // Seeded builtin agents project their stored icon URL. + assert_eq!( + logo_for("claude").as_deref(), + Some("/api/assets/logos/ai-major/claude.svg") + ); + assert_eq!( + logo_for("codex").as_deref(), + Some("/api/assets/logos/tools/coding/codex.svg") + ); + + // Every entry carries a non-empty backend + logo, and backends are unique. + let mut seen = std::collections::HashSet::new(); + for entry in entries { + let backend = entry["backend"].as_str().expect("backend present"); + let logo = entry["logo"].as_str().expect("logo present"); + assert!(!backend.is_empty(), "backend must not be empty"); + assert!(!logo.is_empty(), "logo must not be empty"); + assert!(seen.insert(backend.to_owned()), "backend {backend} duplicated in catalog"); + } +} + +#[tokio::test] +async fn agent_logos_endpoint_includes_disabled_and_missing_rows() { + let (mut app, services, _mock_tm) = build_app_with_mock_tasks().await; + let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "Pass123!").await; + + // A custom/internal row that would be hidden from /api/agents (no command + // on PATH) must still contribute its logo so historical conversations + // referencing it can render an icon. + services + .agent_registry + .repo_handle() + .upsert(&UpsertAgentMetadataParams { + id: "logo-only-row", + icon: Some("/api/assets/logos/brand/aion.svg"), + name: "Logo Only", + name_i18n: None, + description: None, + description_i18n: None, + backend: Some("logo-only-backend"), + agent_type: "acp", + agent_source: "custom", + agent_source_info: Some("{}"), + enabled: false, + command: None, + args: Some("[]"), + env: Some("[]"), + native_skills_dirs: None, + behavior_policy: Some("{}"), + yolo_id: Some("yolo"), + agent_capabilities: None, + auth_methods: None, + config_options: None, + available_modes: None, + available_models: None, + available_commands: None, + sort_order: 1, + }) + .await + .unwrap(); + services.agent_registry.invalidate_and_rehydrate().await.unwrap(); + + let req = get_with_token("/api/agents/logos", &token); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = body_json(resp).await; + let entries = body["data"].as_array().expect("data should be array"); + let entry = entries + .iter() + .find(|entry| entry["backend"].as_str() == Some("logo-only-backend")); + assert!( + entry.is_some(), + "disabled row with an icon must still appear in the logo catalog" + ); + assert_eq!(entry.unwrap()["logo"], "/api/assets/logos/brand/aion.svg"); +} + // ── Message flow with mock agent ──────────────────────────────── #[tokio::test] From 05aea5697c1e7cc75322fb6dba0417223f9072b3 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 21:33:07 +0800 Subject: [PATCH 065/135] fix(cron): prefer assistant backend when creating jobs --- crates/aionui-cron/src/service.rs | 153 ++++++++++-------- .../aionui-cron/tests/service_integration.rs | 63 ++++++++ 2 files changed, 153 insertions(+), 63 deletions(-) diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index be8cbcf47..cbf11367c 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -872,6 +872,95 @@ impl CronService { ); } } + + async fn build_agent_config_from_conversation( + &self, + row: &aionui_db::models::ConversationRow, + ) -> (String, Option) { + let extra = serde_json::from_str::(&row.extra).unwrap_or_else(|_| serde_json::json!({})); + // Both interactive `send_message` and the cron executor parse + // `conversation.model` via the same helper. Keeping the cron-side + // `agent_config.backend` derivation in sync with that parser + // prevents the cached vendor-label fallback (`"aionrs"`) from + // sneaking back in (Sentry ELECTRON-1HM). + let model_resolved = aionui_conversation::task_options::provider_model_from_conversation_row(row); + let model = (!model_resolved.provider_id.is_empty()).then_some(&model_resolved); + let preset_assistant_id = get_string(&extra, &["preset_assistant_id", "presetAssistantId"]); + let assistant_id = get_string(&extra, &["assistant_id", "assistantId"]).or(preset_assistant_id); + let assistant_backend = self + .resolve_assistant_backend(assistant_id.as_deref()) + .await + .unwrap_or(None); + + let backend = if row.r#type == "aionrs" { + model + .map(|value| value.provider_id.clone()) + .filter(|value| !value.is_empty()) + .or_else(|| get_string(&extra, &["backend"])) + .or_else(|| assistant_backend.clone()) + .unwrap_or_else(|| "aionrs".to_owned()) + } else { + assistant_backend + .clone() + .or_else(|| { + model + .map(|value| value.provider_id.clone()) + .filter(|value| !value.is_empty()) + }) + .or_else(|| get_string(&extra, &["backend"])) + .unwrap_or_else(|| row.r#type.clone()) + }; + + let agent_type_enum = serde_json::from_value::(serde_json::Value::String(row.r#type.clone())).ok(); + let full_auto_mode = agent_type_enum + .unwrap_or(AgentType::Acp) + .full_auto_mode_id(Some(backend.as_str())) + .to_owned(); + let agent_config = aionui_api_types::CronAgentConfigWriteDto { + backend, + name: get_string(&extra, &["agent_name", "agentName"]).unwrap_or_else(|| row.name.clone()), + cli_path: get_string(&extra, &["cli_path", "cliPath"]).or_else(|| { + extra + .get("gateway") + .and_then(|gateway| gateway.get("cli_path").or_else(|| gateway.get("cliPath"))) + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) + }), + assistant_id, + mode: Some(full_auto_mode), + model_id: get_string(&extra, &["current_model_id", "currentModelId"]).or_else(|| { + model.and_then(|value| { + value + .use_model + .clone() + .or_else(|| (!value.model.is_empty()).then(|| value.model.clone())) + }) + }), + config_options: None, + workspace: get_string(&extra, &["workspace"]), + }; + + (row.r#type.clone(), Some(agent_config)) + } + + async fn resolve_assistant_backend(&self, assistant_id: Option<&str>) -> Result, CronError> { + let Some(assistant_id) = assistant_id.filter(|value| !value.is_empty()) else { + return Ok(None); + }; + + let Some(definition) = self.assistant_definition_repo.get_by_key(assistant_id).await? else { + return Ok(None); + }; + let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; + + Ok(Some( + overlay + .as_ref() + .and_then(|item| item.agent_backend_override.as_deref()) + .unwrap_or(definition.agent_backend.as_str()) + .to_owned(), + )) + } } // --------------------------------------------------------------------------- @@ -907,7 +996,7 @@ impl aionui_conversation::response_middleware::ICronService for CronService { match self.executor.get_conversation_row(conversation_id).await { Ok(Some(row)) => { let title = Some(row.name.clone()); - let (agent_type, agent_config) = build_agent_config_from_conversation(&row); + let (agent_type, agent_config) = self.build_agent_config_from_conversation(&row).await; (agent_type, title, agent_config) } Ok(None) => ("acp".to_owned(), None, None), @@ -1071,68 +1160,6 @@ impl aionui_conversation::response_middleware::ICronService for CronService { } } -fn build_agent_config_from_conversation( - row: &aionui_db::models::ConversationRow, -) -> (String, Option) { - let extra = serde_json::from_str::(&row.extra).unwrap_or_else(|_| serde_json::json!({})); - // Both interactive `send_message` and the cron executor parse - // `conversation.model` via the same helper. Keeping the cron-side - // `agent_config.backend` derivation in sync with that parser - // prevents the cached vendor-label fallback (`"aionrs"`) from - // sneaking back in (Sentry ELECTRON-1HM). - let model_resolved = aionui_conversation::task_options::provider_model_from_conversation_row(row); - let model = (!model_resolved.provider_id.is_empty()).then_some(&model_resolved); - - let backend = if row.r#type == "aionrs" { - model - .map(|value| value.provider_id.clone()) - .filter(|value| !value.is_empty()) - .or_else(|| get_string(&extra, &["backend"])) - .unwrap_or_else(|| "aionrs".to_owned()) - } else { - model - .map(|value| value.provider_id.clone()) - .filter(|value| !value.is_empty()) - .or_else(|| get_string(&extra, &["backend"])) - .unwrap_or_else(|| row.r#type.clone()) - }; - - let preset_assistant_id = get_string(&extra, &["preset_assistant_id", "presetAssistantId"]); - let assistant_id = get_string(&extra, &["assistant_id", "assistantId"]).or(preset_assistant_id); - - let agent_type_enum = serde_json::from_value::(serde_json::Value::String(row.r#type.clone())).ok(); - // Backend is now the vendor label (e.g. "claude"); pass through as - // &str so `full_auto_mode_id` can key on it without re-parsing. - let full_auto_mode = agent_type_enum - .unwrap_or(AgentType::Acp) - .full_auto_mode_id(Some(backend.as_str())) - .to_owned(); - let agent_config = aionui_api_types::CronAgentConfigWriteDto { - backend, - name: get_string(&extra, &["agent_name", "agentName"]).unwrap_or_else(|| row.name.clone()), - cli_path: get_string(&extra, &["cli_path", "cliPath"]).or_else(|| { - extra - .get("gateway") - .and_then(|gateway| gateway.get("cli_path").or_else(|| gateway.get("cliPath"))) - .and_then(|value| value.as_str()) - .map(ToOwned::to_owned) - }), - assistant_id, - mode: Some(full_auto_mode), - model_id: get_string(&extra, &["current_model_id", "currentModelId"]).or_else(|| { - model.and_then(|value| { - value - .use_model - .clone() - .or_else(|| (!value.model.is_empty()).then(|| value.model.clone())) - }) - }), - config_options: None, - workspace: get_string(&extra, &["workspace"]), - }; - - (row.r#type.clone(), Some(agent_config)) -} fn get_string(extra: &serde_json::Value, keys: &[&str]) -> Option { keys.iter().find_map(|key| { extra diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index 79a77e5a6..5475c29b2 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -355,6 +355,29 @@ impl IConversationRepository for StubConvRepo { created_at: 1000, updated_at: 1000, } + } else if id == "conv_mode_assistant_stale_backend" { + aionui_db::models::ConversationRow { + id: id.into(), + user_id: "u1".into(), + name: "Assistant Stale Backend Chat".into(), + r#type: "acp".into(), + model: None, + status: Some("active".into()), + source: None, + channel_chat_id: None, + extra: serde_json::json!({ + "assistant_id": "assistant-override", + "backend": "claude", + "agent_name": "Override Assistant", + "workspace": ensure_named_workspace_path("aionui-cron-service-assistant-stale-backend-workspace"), + "current_model_id": "gpt-5.4" + }) + .to_string(), + pinned: false, + pinned_at: None, + created_at: 1000, + updated_at: 1000, + } } else { aionui_db::models::ConversationRow { id: id.into(), @@ -1677,6 +1700,46 @@ async fn icron_service_create_job_prefers_model_provider_over_stale_extra_backen assert_eq!(config.model_id.as_deref(), Some("gemini-2.5-pro")); } +#[tokio::test] +async fn icron_service_create_job_prefers_assistant_backend_over_stale_extra_backend() { + let (svc, _, _, _, definition_repo, overlay_repo) = setup_with_assistant_repos().await; + seed_assistant_definition( + &definition_repo, + "asstdef_assistant_override", + "assistant-override", + "codex", + ) + .await; + seed_assistant_overlay(&overlay_repo, "asstdef_assistant_override", Some("aionrs")).await; + + use aionui_conversation::response_middleware::ICronService; + + let params = CronCreateParams { + name: "Assistant Job".into(), + schedule: "0 */10 * * * *".into(), + schedule_description: "every 10 min".into(), + message: "do assistant work".into(), + }; + + let result = ICronService::create_job(&svc, "user_1", "conv_mode_assistant_stale_backend", ¶ms).await; + assert!(result.success); + + let jobs = svc + .list_jobs(&ListCronJobsQuery { + conversation_id: Some("conv_mode_assistant_stale_backend".into()), + }) + .await + .unwrap(); + assert_eq!(jobs.len(), 1); + + let job = &jobs[0]; + let config = job.agent_config.as_ref().expect("agent config should be copied"); + assert_eq!(config.assistant_id.as_deref(), Some("assistant-override")); + assert_eq!(config.backend, "aionrs"); + assert_eq!(config.mode.as_deref(), Some("yolo")); + assert_eq!(config.model_id.as_deref(), Some("gpt-5.4")); +} + #[tokio::test] async fn icron_service_create_job_forces_full_auto_mode_for_generated_crons() { let (svc, _, _) = setup().await; From 383036ed7cb6ff16348e9e7e5aeafa71b94850aa Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 21:55:47 +0800 Subject: [PATCH 066/135] refactor(cron): canonicalize legacy assistant ids in responses --- crates/aionui-cron/src/types.rs | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/aionui-cron/src/types.rs b/crates/aionui-cron/src/types.rs index d39bf7f3c..0f5cd2005 100644 --- a/crates/aionui-cron/src/types.rs +++ b/crates/aionui-cron/src/types.rs @@ -332,13 +332,14 @@ pub fn cron_job_to_response(job: &CronJob) -> CronJobResponse { }; let agent_config_dto = job.agent_config.as_ref().map(|c| { - let assistant_backed = c.assistant_id.is_some(); + let canonical_assistant_id = c.assistant_id.clone().or_else(|| c.custom_agent_id.clone()); + let assistant_backed = canonical_assistant_id.is_some(); CronAgentConfigDto { backend: c.backend.clone(), name: c.name.clone(), cli_path: if assistant_backed { None } else { c.cli_path.clone() }, is_preset: if assistant_backed { None } else { c.is_preset }, - assistant_id: c.assistant_id.clone(), + assistant_id: canonical_assistant_id, custom_agent_id: if assistant_backed { None } else { @@ -875,6 +876,33 @@ mod tests { assert_eq!(resp.target.execution_mode.as_deref(), Some("new_conversation")); } + #[test] + fn domain_to_dto_promotes_legacy_custom_agent_id_to_assistant_id() { + let job = CronJob { + agent_config: Some(CronAgentConfig { + backend: "claude".into(), + name: "Legacy Assistant Job".into(), + cli_path: Some("/tmp/claude".into()), + is_preset: Some(false), + assistant_id: None, + custom_agent_id: Some("legacy-assistant".into()), + preset_agent_type: None, + mode: Some("default".into()), + model_id: Some("claude-sonnet-4".into()), + config_options: None, + workspace: Some("/tmp/project".into()), + }), + ..sample_job() + }; + + let resp = cron_job_to_response(&job); + let config = resp.metadata.agent_config.expect("assistant config should be present"); + + assert_eq!(config.assistant_id.as_deref(), Some("legacy-assistant")); + assert!(config.custom_agent_id.is_none()); + assert!(config.cli_path.is_none()); + } + // -- DTO → Domain schedule ------------------------------------------------ #[test] From 9a9985e486fb586ad69142fb37c9cd0178cfb601 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 23:14:07 +0800 Subject: [PATCH 067/135] refactor(channel): clear sessions on settings updates --- crates/aionui-app/tests/channel_e2e.rs | 102 +++++++++++++++++++++++++ crates/aionui-channel/src/routes.rs | 2 + 2 files changed, 104 insertions(+) diff --git a/crates/aionui-app/tests/channel_e2e.rs b/crates/aionui-app/tests/channel_e2e.rs index 1e6ba456e..f464fdac6 100644 --- a/crates/aionui-app/tests/channel_e2e.rs +++ b/crates/aionui-app/tests/channel_e2e.rs @@ -5,6 +5,9 @@ mod common; +use aionui_common::now_ms; +use aionui_db::models::{AssistantSessionRow, AssistantUserRow}; +use aionui_db::{IChannelRepository, SqliteChannelRepository}; use axum::http::StatusCode; use serde_json::json; use tower::ServiceExt; @@ -393,6 +396,105 @@ async fn put_channel_default_model_setting_persists_model_ref() { ); } +#[tokio::test] +async fn put_channel_assistant_setting_clears_active_sessions() { + let (mut app, services) = build_app().await; + let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + let repo: std::sync::Arc = + std::sync::Arc::new(SqliteChannelRepository::new(services.database.pool().clone())); + + let now = now_ms(); + repo.create_user(&AssistantUserRow { + id: "user-channel-assistant".to_owned(), + platform_user_id: "user-channel-assistant".to_owned(), + platform_type: "lark".to_owned(), + display_name: Some("Channel Assistant User".to_owned()), + authorized_at: now, + last_active: Some(now), + session_id: None, + }) + .await + .unwrap(); + let new_session = AssistantSessionRow { + id: "sess-channel-assistant".to_owned(), + user_id: "user-channel-assistant".to_owned(), + agent_type: "acp".to_owned(), + conversation_id: None, + workspace: None, + chat_id: Some("chat-channel-assistant".to_owned()), + created_at: now, + last_activity: now, + }; + repo.get_or_create_session("user-channel-assistant", "chat-channel-assistant", &new_session) + .await + .unwrap(); + assert_eq!(repo.get_all_sessions().await.unwrap().len(), 1); + + let req = json_with_token( + "PUT", + "/api/channel/settings/lark/assistant", + json!({ + "assistant_id": "assistant-1", + }), + &token, + &csrf, + ); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + assert!(repo.get_all_sessions().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn put_channel_default_model_setting_clears_active_sessions() { + let (mut app, services) = build_app().await; + let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + let repo: std::sync::Arc = + std::sync::Arc::new(SqliteChannelRepository::new(services.database.pool().clone())); + + let now = now_ms(); + repo.create_user(&AssistantUserRow { + id: "user-channel-model".to_owned(), + platform_user_id: "user-channel-model".to_owned(), + platform_type: "lark".to_owned(), + display_name: Some("Channel Model User".to_owned()), + authorized_at: now, + last_active: Some(now), + session_id: None, + }) + .await + .unwrap(); + let new_session = AssistantSessionRow { + id: "sess-channel-model".to_owned(), + user_id: "user-channel-model".to_owned(), + agent_type: "acp".to_owned(), + conversation_id: None, + workspace: None, + chat_id: Some("chat-channel-model".to_owned()), + created_at: now, + last_activity: now, + }; + repo.get_or_create_session("user-channel-model", "chat-channel-model", &new_session) + .await + .unwrap(); + assert_eq!(repo.get_all_sessions().await.unwrap().len(), 1); + + let req = json_with_token( + "PUT", + "/api/channel/settings/lark/default-model", + json!({ + "id": "provider-1", + "use_model": "gemini-2.5-pro", + }), + &token, + &csrf, + ); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + assert!(repo.get_all_sessions().await.unwrap().is_empty()); +} + // SS-1: Sync valid platform clears sessions #[tokio::test] async fn sync_settings_valid() { diff --git a/crates/aionui-channel/src/routes.rs b/crates/aionui-channel/src/routes.rs index 51b17ca48..cc190d172 100644 --- a/crates/aionui-channel/src/routes.rs +++ b/crates/aionui-channel/src/routes.rs @@ -588,6 +588,7 @@ async fn set_channel_assistant_setting( let Json(req) = body.map_err(ApiError::from)?; state.settings_service.set_assistant_setting(platform, &req).await?; + state.session_manager.clear_all_sessions().await?; Ok(Json(ApiResponse::ok(BridgeResponse { success: true, @@ -607,6 +608,7 @@ async fn set_channel_default_model_setting( let Json(req) = body.map_err(ApiError::from)?; state.settings_service.set_model_setting(platform, &req).await?; + state.session_manager.clear_all_sessions().await?; Ok(Json(ApiResponse::ok(BridgeResponse { success: true, From 6de216934414a8de6a1db7d4a6063e3187aae475 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 17 Jun 2026 23:39:37 +0800 Subject: [PATCH 068/135] refactor(team): query models by assistant identity --- .../aionui-app/src/commands/cmd_team_guide.rs | 16 ++++---- crates/aionui-team/src/guide/server.rs | 25 ++++++++++++- crates/aionui-team/src/mcp/server.rs | 37 ++++++++++++++++++- crates/aionui-team/src/mcp/tools.rs | 33 ++++++++++++----- .../src/prompts/prompt_templates/lead.txt | 4 +- crates/aionui-team/src/prompts/team_guide.rs | 4 +- .../aionui-team/src/service/spawn_support.rs | 32 ++++++++++++++-- 7 files changed, 123 insertions(+), 28 deletions(-) diff --git a/crates/aionui-app/src/commands/cmd_team_guide.rs b/crates/aionui-app/src/commands/cmd_team_guide.rs index 5020cd9f5..3f3121750 100644 --- a/crates/aionui-app/src/commands/cmd_team_guide.rs +++ b/crates/aionui-app/src/commands/cmd_team_guide.rs @@ -136,9 +136,9 @@ struct CreateTeamParams { #[derive(Deserialize, schemars::JsonSchema)] struct ListModelsParams { - /// Agent type/backend to query (e.g. "gemini", "claude", "codex"). Shows all when omitted. + /// Assistant ID to query. Shows all backends when omitted. #[serde(default)] - agent_type: Option, + assistant_id: Option, } #[derive(Deserialize, schemars::JsonSchema)] @@ -213,9 +213,9 @@ struct ShutdownAgentParams { #[derive(Deserialize, schemars::JsonSchema)] struct TeamListModelsParams { - /// Agent type to filter models (e.g. "claude", "codex"). Shows all when omitted. + /// Assistant ID to query. Shows all backends when omitted. #[serde(default)] - agent_type: Option, + assistant_id: Option, } #[derive(Deserialize, schemars::JsonSchema)] @@ -248,13 +248,13 @@ impl GuideServer { #[tool( name = "aion_list_models", - description = "Query available models for team agent types. Pass agent_type to filter, or omit to see all." + description = "Query available models for team assistants. Pass assistant_id to query a specific assistant, or omit it to see all backends." )] async fn list_models(&self, Parameters(params): Parameters) -> CallToolResult { self.forward_tool( "aion_list_models", &serde_json::json!({ - "agent_type": params.agent_type, + "assistant_id": params.assistant_id, }), ) .await @@ -365,13 +365,13 @@ impl GuideServer { #[tool( name = "team_list_models", - description = "Query available models for team agent types." + description = "Query available models for team assistants." )] async fn team_list_models(&self, Parameters(params): Parameters) -> CallToolResult { self.forward_tool( "team_list_models", &serde_json::json!({ - "agent_type": params.agent_type, + "assistant_id": params.assistant_id, }), ) .await diff --git a/crates/aionui-team/src/guide/server.rs b/crates/aionui-team/src/guide/server.rs index 1951735d0..bee07be67 100644 --- a/crates/aionui-team/src/guide/server.rs +++ b/crates/aionui-team/src/guide/server.rs @@ -132,7 +132,30 @@ async fn handle_tool_request( "aion_list_models" => { let result = match state.service.read().await.upgrade() { Some(svc) => { - let mut base = svc.list_models_from_db(None).await; + if args.get("backend").is_some() { + return Json(serde_json::json!({ + "error": "backend is no longer accepted; use assistant_id" + })) + .into_response(); + } + if args.get("agent_type").is_some() { + return Json(serde_json::json!({ + "error": "agent_type is no longer accepted; use assistant_id" + })) + .into_response(); + } + let mut base = match svc + .list_models_from_db(args.get("assistant_id").and_then(serde_json::Value::as_str)) + .await + { + Ok(value) => value, + Err(error) => { + return Json(serde_json::json!({ + "error": error.to_string() + })) + .into_response(); + } + }; // Guide surfaces Gemini even if not in spawn whitelist if let Some(types) = base.get_mut("agent_types").and_then(serde_json::Value::as_array_mut) { let has_gemini = types diff --git a/crates/aionui-team/src/mcp/server.rs b/crates/aionui-team/src/mcp/server.rs index 7846f4930..8f5abae63 100644 --- a/crates/aionui-team/src/mcp/server.rs +++ b/crates/aionui-team/src/mcp/server.rs @@ -450,10 +450,19 @@ pub(crate) async fn dispatch_tool( } async fn exec_list_models(args: &Value, service: &Weak) -> Result { - let agent_type_filter = args.get("agent_type").and_then(Value::as_str); + if args.get("backend").is_some() { + return Err("backend is no longer accepted; use assistant_id".to_owned()); + } + if args.get("agent_type").is_some() { + return Err("agent_type is no longer accepted; use assistant_id".to_owned()); + } + let assistant_id_filter = args.get("assistant_id").and_then(Value::as_str); let value = match service.upgrade() { - Some(svc) => svc.list_models_from_db(agent_type_filter).await, + Some(svc) => svc + .list_models_from_db(assistant_id_filter) + .await + .map_err(|error| error.to_string())?, None => handle_team_list_models(args), }; serde_json::to_string_pretty(&value).map_err(|e| format!("Serialization error: {e}")) @@ -996,4 +1005,28 @@ mod tests { "expected assistant_id requirement, got {err:?}" ); } + + #[tokio::test] + async fn exec_list_models_rejects_legacy_backend_alias() { + let service: Weak = Weak::new(); + let args = json!({ "backend": "claude" }); + let result = exec_list_models(&args, &service).await; + let err = result.expect_err("legacy backend alias must be rejected"); + assert!( + err.contains("backend is no longer accepted"), + "expected explicit backend rejection, got {err:?}" + ); + } + + #[tokio::test] + async fn exec_list_models_rejects_legacy_agent_type_alias() { + let service: Weak = Weak::new(); + let args = json!({ "agent_type": "claude" }); + let result = exec_list_models(&args, &service).await; + let err = result.expect_err("legacy agent_type alias must be rejected"); + assert!( + err.contains("agent_type is no longer accepted"), + "expected explicit agent_type rejection, got {err:?}" + ); + } } diff --git a/crates/aionui-team/src/mcp/tools.rs b/crates/aionui-team/src/mcp/tools.rs index 176f4c2fd..affed8e1f 100644 --- a/crates/aionui-team/src/mcp/tools.rs +++ b/crates/aionui-team/src/mcp/tools.rs @@ -40,7 +40,7 @@ Use this to: - See all available backends and their models at once - Verify a model ID is valid for the backend behind a chosen assistant or fallback backend -Pass agent_type to query a specific backend, or omit it to see all backends."; +Pass assistant_id to query models for a specific assistant, or omit it to see all backends."; /// Description for `team_describe_assistant` — verbatim from team-prompts.md §5.2. pub const TEAM_DESCRIBE_ASSISTANT_DESCRIPTION: &str = @@ -180,7 +180,7 @@ pub fn all_tool_descriptors() -> Vec { input_schema: json!({ "type": "object", "properties": { - "agent_type": { "type": "string", "description": "Backend to query (e.g. \"gemini\", \"claude\", \"codex\"). Shows all backends when omitted." } + "assistant_id": { "type": "string", "description": "Assistant ID to query. When provided, returns models for the backend behind that assistant. Shows all backends when omitted." } } }), }, @@ -324,11 +324,11 @@ pub fn handle_team_list_models(_args: &Value) -> Value { /// Build `team_list_models` response from DB rows. Reads each enabled, /// team-capable backend's `available_models` column. Filters by -/// `agent_type` if provided. For internal agents (backend=NULL), +/// `backend` if provided. For internal agents (backend=NULL), /// `provider_models` supplies the aggregated models from the providers table. pub fn build_list_models_from_rows( rows: &[AgentMetadataRow], - agent_type_filter: Option<&str>, + backend_filter: Option<&str>, provider_models: &[String], ) -> Value { use aionui_api_types::BehaviorPolicy; @@ -363,8 +363,8 @@ pub fn build_list_models_from_rows( } } - // Apply agent_type filter - if let Some(filter) = agent_type_filter + // Apply backend filter + if let Some(filter) = backend_filter && key != filter { continue; @@ -645,11 +645,24 @@ mod tests { .starts_with("Query available models for assistant backends.") ); assert!( - desc.description - .contains("Pass agent_type to query a specific backend, or omit it to see all backends.") + desc.description.contains( + "Pass assistant_id to query models for a specific assistant, or omit it to see all backends." + ) ); } + #[test] + fn team_list_models_schema_prefers_assistant_id() { + let desc = all_tool_descriptors() + .into_iter() + .find(|d| d.name == "team_list_models") + .unwrap(); + let props = desc.input_schema["properties"].as_object().unwrap(); + assert!(props.contains_key("assistant_id")); + assert!(!props.contains_key("agent_type")); + assert!(!props.contains_key("backend")); + } + #[test] fn team_describe_assistant_descriptor_text_matches() { let desc = all_tool_descriptors() @@ -794,7 +807,7 @@ mod tests { } #[test] - fn build_list_models_from_rows_filters_by_agent_type() { + fn build_list_models_from_rows_filters_by_backend() { let rows = vec![ make_agent_row("claude", true, r#"[{"id":"claude-sonnet-4","name":"Sonnet 4"}]"#), make_agent_row("codebuddy", true, r#"[{"id":"cb-pro","name":"Pro"}]"#), @@ -908,7 +921,7 @@ mod tests { } #[test] - fn build_list_models_from_rows_filters_null_backend_by_agent_type() { + fn build_list_models_from_rows_filters_null_backend_by_backend() { let mut aionrs_row = make_agent_row("aionrs", true, r#"[{"id":"aionrs-default","name":"AionRS"}]"#); aionrs_row.backend = None; aionrs_row.agent_type = "aionrs".to_owned(); diff --git a/crates/aionui-team/src/prompts/prompt_templates/lead.txt b/crates/aionui-team/src/prompts/prompt_templates/lead.txt index 39e6e3d1b..2a86457d2 100644 --- a/crates/aionui-team/src/prompts/prompt_templates/lead.txt +++ b/crates/aionui-team/src/prompts/prompt_templates/lead.txt @@ -24,7 +24,7 @@ Use `team_members` and `team_task_list` to check current team state. ## Workflow 1. Receive user request 2. Analyze the request and decide whether the current team is enough -3. If additional teammates would help, FIRST call `team_list_models` to check available models for each assistant backend you plan to use +3. If additional teammates would help, FIRST call `team_list_models` to check available models for each assistant you plan to use 4. Then reply in text with a staffing proposal 5. Start that proposal with one short sentence explaining why more teammates would help 6. Present the proposed lineup as a table with: teammate name, responsibility, recommended assistant, and recommended model (from team_list_models results).${presetFormattingStepRule} @@ -40,7 +40,7 @@ Use `team_members` and `team_task_list` to check current team state. 15. Synthesize results and respond to the user ## Model Selection Guidelines -- Before spawning teammates, use `team_list_models` to check available models for that assistant backend +- Before spawning teammates, use `team_list_models` to check available models for that assistant - You MUST use the exact model ID strings returned by team_list_models — never shorten or invent model names - For complex reasoning tasks: prefer the strongest model available for that backend - For routine tasks: prefer faster/cheaper models from the list diff --git a/crates/aionui-team/src/prompts/team_guide.rs b/crates/aionui-team/src/prompts/team_guide.rs index ace96a2d5..cef33f92a 100644 --- a/crates/aionui-team/src/prompts/team_guide.rs +++ b/crates/aionui-team/src/prompts/team_guide.rs @@ -46,7 +46,7 @@ You can create a multi-agent Team for the user. If case 2 applies, ask at most once whether the user wants to bring in a Team. Keep it brief and optional. If the user says no, ignores it, or prefers solo help, continue solo and do not mention Team again. ### How to proceed when Team is requested or approved (STRICT — follow every step, do NOT skip) -1. FIRST call `aion_list_models` to check available models for each assistant backend you plan to use. +1. FIRST call `aion_list_models` to check available models for each assistant you plan to use. 2. Explain in one sentence why the Team setup helps this task. 3. Present a team configuration table: role name, responsibility, recommended assistant, and recommended model (from aion_list_models results) for each member. Example format: | Role | Responsibility | Assistant | Model | @@ -118,7 +118,7 @@ Handle the task yourself in the current chat by default. Do NOT proactively reco If case 2 applies, ask at most once whether the user wants to bring in a Team. Keep it brief and optional. If the user says no, ignores it, or prefers solo help, continue solo and do not mention Team again.\n\ \n\ ### How to proceed when Team is requested or approved (STRICT — follow every step, do NOT skip)\n\ -1. FIRST call `aion_list_models` to check available models for each assistant backend you plan to use.\n\ +1. FIRST call `aion_list_models` to check available models for each assistant you plan to use.\n\ 2. Explain in one sentence why the Team setup helps this task.\n\ 3. Present a team configuration table: role name, responsibility, recommended assistant, and recommended model (from aion_list_models results) for each member. Example format:\n \ | Role | Responsibility | Assistant | Model |\n \ diff --git a/crates/aionui-team/src/service/spawn_support.rs b/crates/aionui-team/src/service/spawn_support.rs index e4e5dc684..1dd33db54 100644 --- a/crates/aionui-team/src/service/spawn_support.rs +++ b/crates/aionui-team/src/service/spawn_support.rs @@ -224,12 +224,38 @@ impl TeamSessionService { /// Falls back to the hardcoded response if the DB query fails. /// For internal agents (like aionrs with backend=NULL), enriches /// with models from the providers table. - pub(crate) async fn list_models_from_db(&self, agent_type_filter: Option<&str>) -> serde_json::Value { + pub(crate) async fn list_models_from_db( + &self, + assistant_id_filter: Option<&str>, + ) -> Result { let Ok(rows) = self.agent_metadata_repo.list_all().await else { - return crate::mcp::tools::handle_team_list_models(&serde_json::Value::Null); + return Ok(crate::mcp::tools::handle_team_list_models(&serde_json::Value::Null)); + }; + let backend_filter = match assistant_id_filter.map(str::trim).filter(|value| !value.is_empty()) { + Some(assistant_key) => { + let definition = self + .assistant_definition_repo + .get_by_key(assistant_key) + .await? + .ok_or_else(|| TeamError::InvalidRequest(format!("Assistant not found: {assistant_key}")))?; + let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; + Some( + overlay + .as_ref() + .and_then(|row| row.agent_backend_override.as_deref()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(definition.agent_backend.as_str()) + .to_owned(), + ) + } + None => None, }; let provider_models = self.collect_provider_models().await; - crate::mcp::tools::build_list_models_from_rows(&rows, agent_type_filter, &provider_models) + Ok(crate::mcp::tools::build_list_models_from_rows( + &rows, + backend_filter.as_deref(), + &provider_models, + )) } /// Collect all enabled provider model IDs grouped by provider name. From 93d8ca58b149ab9f3fbe24e385d8e4700b92a692 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 02:37:02 +0800 Subject: [PATCH 069/135] refactor(cron): reject legacy write agent fields --- crates/aionui-api-types/src/cron.rs | 34 +++++++++++++++++++++++++++++ crates/aionui-app/tests/cron_e2e.rs | 4 +--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/aionui-api-types/src/cron.rs b/crates/aionui-api-types/src/cron.rs index 0777c98f8..629481761 100644 --- a/crates/aionui-api-types/src/cron.rs +++ b/crates/aionui-api-types/src/cron.rs @@ -61,6 +61,7 @@ pub struct CronAgentConfigDto { } #[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] pub struct CronAgentConfigWriteDto { pub backend: String, pub name: String, @@ -224,6 +225,39 @@ pub struct CronJobExecutedEvent { pub error: Option, } +#[cfg(test)] +mod write_tests { + use super::CronAgentConfigWriteDto; + + #[test] + fn cron_agent_config_write_rejects_legacy_custom_agent_id() { + let err = serde_json::from_value::(serde_json::json!({ + "backend": "claude", + "name": "Helper", + "assistant_id": "assistant-1", + "custom_agent_id": "legacy-agent", + })) + .expect_err("legacy custom_agent_id must be rejected"); + + assert!(err.to_string().contains("custom_agent_id")); + } + + #[test] + fn cron_agent_config_write_rejects_legacy_preset_flags() { + let err = serde_json::from_value::(serde_json::json!({ + "backend": "claude", + "name": "Helper", + "assistant_id": "assistant-1", + "is_preset": true, + "preset_agent_type": "claude", + })) + .expect_err("legacy preset fields must be rejected"); + + let message = err.to_string(); + assert!(message.contains("is_preset") || message.contains("preset_agent_type")); + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct CronJobRemovedPayload { pub job_id: String, diff --git a/crates/aionui-app/tests/cron_e2e.rs b/crates/aionui-app/tests/cron_e2e.rs index 35af342a5..61fe95c4e 100644 --- a/crates/aionui-app/tests/cron_e2e.rs +++ b/crates/aionui-app/tests/cron_e2e.rs @@ -680,9 +680,7 @@ async fn rn1c_run_now_new_conversation_preset_assistant_uses_fixed_assistant_mcp "agent_config": { "backend": "codex", "name": "Cron MCP Assistant", - "is_preset": true, - "assistant_id": "u-fixed-mcp", - "preset_agent_type": "codex" + "assistant_id": "u-fixed-mcp" } }), &token, From 879f439b98693f359805300a5a23d009b056ff26 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 03:06:15 +0800 Subject: [PATCH 070/135] refactor(cron): strip legacy backend from assistant responses --- crates/aionui-api-types/src/cron.rs | 13 ++++++------ crates/aionui-cron/src/types.rs | 32 ++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/crates/aionui-api-types/src/cron.rs b/crates/aionui-api-types/src/cron.rs index 629481761..b8f95617a 100644 --- a/crates/aionui-api-types/src/cron.rs +++ b/crates/aionui-api-types/src/cron.rs @@ -38,7 +38,8 @@ pub enum CronScheduleDto { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct CronAgentConfigDto { - pub backend: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend: Option, pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub cli_path: Option, @@ -437,7 +438,7 @@ mod tests { "workspace": "/tmp/ws" }); let c: CronAgentConfigDto = serde_json::from_value(raw).unwrap(); - assert_eq!(c.backend, "acp"); + assert_eq!(c.backend.as_deref(), Some("acp")); assert_eq!(c.name, "Claude Agent"); assert_eq!(c.cli_path.as_deref(), Some("/usr/bin/claude")); assert_eq!(c.is_preset, Some(true)); @@ -451,7 +452,7 @@ mod tests { fn agent_config_minimal() { let raw = json!({"backend": "openai", "name": "GPT"}); let c: CronAgentConfigDto = serde_json::from_value(raw).unwrap(); - assert_eq!(c.backend, "openai"); + assert_eq!(c.backend.as_deref(), Some("openai")); assert_eq!(c.name, "GPT"); assert!(c.cli_path.is_none()); assert!(c.is_preset.is_none()); @@ -461,7 +462,7 @@ mod tests { #[test] fn agent_config_serialize_omits_none() { let c = CronAgentConfigDto { - backend: "acp".into(), + backend: Some("acp".into()), name: "Test".into(), cli_path: None, is_preset: None, @@ -482,7 +483,7 @@ mod tests { #[test] fn agent_config_roundtrip() { let c = CronAgentConfigDto { - backend: "acp".into(), + backend: Some("acp".into()), name: "Agent".into(), cli_path: Some("/bin/x".into()), is_preset: Some(false), @@ -526,7 +527,7 @@ mod tests { created_at: 1700000000000, updated_at: 1700001000000, agent_config: Some(CronAgentConfigDto { - backend: "acp".into(), + backend: Some("acp".into()), name: "Claude".into(), cli_path: None, is_preset: None, diff --git a/crates/aionui-cron/src/types.rs b/crates/aionui-cron/src/types.rs index 0f5cd2005..587d6795d 100644 --- a/crates/aionui-cron/src/types.rs +++ b/crates/aionui-cron/src/types.rs @@ -334,8 +334,9 @@ pub fn cron_job_to_response(job: &CronJob) -> CronJobResponse { let agent_config_dto = job.agent_config.as_ref().map(|c| { let canonical_assistant_id = c.assistant_id.clone().or_else(|| c.custom_agent_id.clone()); let assistant_backed = canonical_assistant_id.is_some(); + let preserve_backend = !assistant_backed || job.agent_type == "aionrs"; CronAgentConfigDto { - backend: c.backend.clone(), + backend: preserve_backend.then(|| c.backend.clone()), name: c.name.clone(), cli_path: if assistant_backed { None } else { c.cli_path.clone() }, is_preset: if assistant_backed { None } else { c.is_preset }, @@ -850,6 +851,7 @@ mod tests { let config = resp.metadata.agent_config.expect("assistant config should be present"); assert_eq!(config.assistant_id.as_deref(), Some("assistant-1")); + assert!(config.backend.is_none()); assert!(config.cli_path.is_none()); assert!(config.is_preset.is_none()); assert!(config.custom_agent_id.is_none()); @@ -899,10 +901,38 @@ mod tests { let config = resp.metadata.agent_config.expect("assistant config should be present"); assert_eq!(config.assistant_id.as_deref(), Some("legacy-assistant")); + assert!(config.backend.is_none()); assert!(config.custom_agent_id.is_none()); assert!(config.cli_path.is_none()); } + #[test] + fn domain_to_dto_keeps_provider_backend_for_aionrs_assistant_jobs() { + let job = CronJob { + agent_type: "aionrs".into(), + agent_config: Some(CronAgentConfig { + backend: "gemini".into(), + name: "Gemini Bare Assistant".into(), + cli_path: None, + is_preset: None, + assistant_id: Some("bare-gemini".into()), + custom_agent_id: None, + preset_agent_type: None, + mode: Some("default".into()), + model_id: Some("gemini-2.5-pro".into()), + config_options: None, + workspace: Some("/tmp/project".into()), + }), + ..sample_job() + }; + + let resp = cron_job_to_response(&job); + let config = resp.metadata.agent_config.expect("assistant config should be present"); + + assert_eq!(config.assistant_id.as_deref(), Some("bare-gemini")); + assert_eq!(config.backend.as_deref(), Some("gemini")); + } + // -- DTO → Domain schedule ------------------------------------------------ #[test] From bf647f6d97d10d4dd6e37df954b1eff31e06c981 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 03:24:14 +0800 Subject: [PATCH 071/135] refactor(team): route stdio model lookup by assistant --- .../aionui-app/src/commands/cmd_team_stdio.rs | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/crates/aionui-app/src/commands/cmd_team_stdio.rs b/crates/aionui-app/src/commands/cmd_team_stdio.rs index 8c656c527..6336af73c 100644 --- a/crates/aionui-app/src/commands/cmd_team_stdio.rs +++ b/crates/aionui-app/src/commands/cmd_team_stdio.rs @@ -192,9 +192,9 @@ struct ShutdownAgentParams { #[derive(Deserialize, schemars::JsonSchema)] struct ListModelsParams { - /// Agent type/backend to query (e.g. "gemini", "claude", "codex"). Shows all when omitted. + /// Assistant ID to query. Shows all backends when omitted. #[serde(default)] - agent_type: Option, + assistant_id: Option, } #[derive(Deserialize, schemars::JsonSchema)] @@ -310,12 +310,12 @@ impl TeamStdioServer { #[tool( name = "team_list_models", - description = "Query available models for team agent types. Returns the real-time model list that matches the frontend model selector.\n\nUse this to:\n- Check what models are available before spawning an agent with a specific model\n- See all available agent types and their models at once\n- Verify a model ID is valid for a given agent type\n\nPass agent_type to query a specific backend, or omit it to see all." + description = "Query available models for team assistants. Returns the real-time model list that matches the frontend model selector.\n\nUse this to:\n- Check what models are available before spawning an agent with a specific model\n- See all available assistant backends and their models at once\n- Verify a model ID is valid for a given assistant\n\nPass assistant_id to query a specific assistant, or omit it to see all backends." )] async fn list_models(&self, Parameters(params): Parameters) -> CallToolResult { self.forward_to_tcp( "team_list_models", - &serde_json::json!({ "agent_type": params.agent_type }), + &serde_json::json!({ "assistant_id": params.assistant_id }), ) .await } @@ -647,6 +647,55 @@ mod tests { assert!(!serialized.contains("conv-secret-123")); } + #[tokio::test] + async fn list_models_forwards_assistant_id_argument() { + let listener = TcpListener::bind((CONNECT_HOST, 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let accept_task = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let _init = read_frame(&mut socket).await.unwrap(); + let init_response = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", + "id": 1, + "result": {} + })) + .unwrap(); + write_frame(&mut socket, &init_response).await.unwrap(); + + let call = read_frame(&mut socket).await.unwrap(); + let call_value: serde_json::Value = serde_json::from_slice(&call).unwrap(); + let arguments = &call_value["params"]["arguments"]; + assert_eq!(arguments["assistant_id"], json!("assistant-social-job-publisher")); + assert!(arguments.get("agent_type").is_none()); + + let tool_response = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", + "id": 2, + "result": { + "content": [{ "type": "text", "text": "ok" }], + "isError": false + } + })) + .unwrap(); + write_frame(&mut socket, &tool_response).await.unwrap(); + }); + let server = TeamStdioServer { + port, + token: "dummy-token".into(), + slot_id: "dummy-slot".into(), + }; + + let result = server + .list_models(Parameters(ListModelsParams { + assistant_id: Some("assistant-social-job-publisher".into()), + })) + .await; + + accept_task.await.unwrap(); + assert_eq!(result.is_error, Some(false)); + assert_eq!(first_text(&result), "ok"); + } + #[test] fn parse_tool_response_extracts_content_text() { let result = parse_tool_response( From 7107f1934c3e9792bf907cf336b11ea50f54c1dc Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 03:36:26 +0800 Subject: [PATCH 072/135] refactor(team): rename list models response to backends --- crates/aionui-team/src/guide/handlers.rs | 22 ++++---- crates/aionui-team/src/mcp/tools.rs | 64 ++++++++++++------------ 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/crates/aionui-team/src/guide/handlers.rs b/crates/aionui-team/src/guide/handlers.rs index bdbe6c1ef..d1e206db3 100644 --- a/crates/aionui-team/src/guide/handlers.rs +++ b/crates/aionui-team/src/guide/handlers.rs @@ -62,9 +62,9 @@ pub fn parse_create_team_args(args: &Value, caller_workspace: Option<&str>) -> R /// option regardless of the spawn whitelist. pub fn handle_aion_list_models() -> Value { let mut base = handle_team_list_models(&Value::Null); - if let Some(agent_types) = base.get_mut("agent_types").and_then(Value::as_array_mut) { - agent_types.push(json!({ - "type": "gemini", + if let Some(backends) = base.get_mut("backends").and_then(Value::as_array_mut) { + backends.push(json!({ + "backend": "gemini", "models": ["gemini-2.5-pro", "gemini-2.5-flash"] })); } @@ -157,13 +157,13 @@ mod tests { } #[test] - fn returns_agent_types_array() { + fn returns_backends_array() { let value = handle_aion_list_models(); let types = value - .get("agent_types") + .get("backends") .and_then(Value::as_array) - .expect("agent_types must be an array"); - let names: Vec<&str> = types.iter().filter_map(|t| t.get("type")?.as_str()).collect(); + .expect("backends must be an array"); + let names: Vec<&str> = types.iter().filter_map(|t| t.get("backend")?.as_str()).collect(); assert!(names.contains(&"claude")); assert!(names.contains(&"codex")); assert!(names.contains(&"gemini")); @@ -172,10 +172,10 @@ mod tests { #[test] fn every_entry_has_models_list() { let value = handle_aion_list_models(); - for entry in value["agent_types"].as_array().unwrap() { + for entry in value["backends"].as_array().unwrap() { let models = entry["models"].as_array().expect("models must be array"); assert!(!models.is_empty(), "models list must not be empty"); - assert!(entry["type"].as_str().map(|s| !s.is_empty()).unwrap_or(false)); + assert!(entry["backend"].as_str().map(|s| !s.is_empty()).unwrap_or(false)); } } @@ -191,9 +191,9 @@ mod tests { } fn find_entry<'a>(value: &'a Value, backend: &str) -> Option<&'a Value> { - value["agent_types"] + value["backends"] .as_array()? .iter() - .find(|entry| entry["type"].as_str() == Some(backend)) + .find(|entry| entry["backend"].as_str() == Some(backend)) } } diff --git a/crates/aionui-team/src/mcp/tools.rs b/crates/aionui-team/src/mcp/tools.rs index affed8e1f..73a621022 100644 --- a/crates/aionui-team/src/mcp/tools.rs +++ b/crates/aionui-team/src/mcp/tools.rs @@ -306,16 +306,16 @@ pub fn parse_tool_call( // --------------------------------------------------------------------------- /// Phase-1 minimal `team_list_models` handler. Returns a hard-coded -/// agent-type → models mapping. Used as fallback when DB is unavailable. +/// backend → models mapping. Used as fallback when DB is unavailable. pub fn handle_team_list_models(_args: &Value) -> Value { json!({ - "agent_types": [ + "backends": [ { - "type": "claude", + "backend": "claude", "models": ["claude-sonnet-4", "claude-opus-4"] }, { - "type": "codex", + "backend": "codex", "models": ["codex-mini-latest"] } ] @@ -334,7 +334,7 @@ pub fn build_list_models_from_rows( use aionui_api_types::BehaviorPolicy; use aionui_common::constants::is_team_capable; - let mut agent_types: Vec = Vec::new(); + let mut backends: Vec = Vec::new(); for row in rows { if !row.enabled { @@ -372,8 +372,8 @@ pub fn build_list_models_from_rows( // For internal agents (aionrs), use provider models if is_internal && !provider_models.is_empty() { - agent_types.push(json!({ - "type": key, + backends.push(json!({ + "backend": key, "models": provider_models, })); continue; @@ -413,13 +413,13 @@ pub fn build_list_models_from_rows( }) .unwrap_or_default(); - agent_types.push(json!({ - "type": key, + backends.push(json!({ + "backend": key, "models": models, })); } - json!({ "agent_types": agent_types }) + json!({ "backends": backends }) } // --------------------------------------------------------------------------- @@ -751,14 +751,14 @@ mod tests { #[test] fn team_list_models_handler_returns_non_error() { let value = handle_team_list_models(&json!({})); - let agent_types = value - .get("agent_types") + let backends = value + .get("backends") .and_then(|v| v.as_array()) - .expect("agent_types array missing"); - assert!(!agent_types.is_empty()); - let types: Vec<&str> = agent_types + .expect("backends array missing"); + assert!(!backends.is_empty()); + let types: Vec<&str> = backends .iter() - .filter_map(|e| e.get("type").and_then(|v| v.as_str())) + .filter_map(|e| e.get("backend").and_then(|v| v.as_str())) .collect(); assert!(types.contains(&"claude")); assert!(types.contains(&"codex")); @@ -772,11 +772,11 @@ mod tests { make_agent_row("disabled-one", false, r#"[{"id":"m1","name":"M1"}]"#), ]; let value = build_list_models_from_rows(&rows, None, &[]); - let types: Vec<&str> = value["agent_types"] + let types: Vec<&str> = value["backends"] .as_array() .unwrap() .iter() - .filter_map(|e| e["type"].as_str()) + .filter_map(|e| e["backend"].as_str()) .collect(); assert!(types.contains(&"claude")); assert!(types.contains(&"codebuddy")); @@ -791,11 +791,11 @@ mod tests { r#"[{"id":"claude-opus-4","name":"Opus 4"},{"id":"claude-sonnet-4","name":"Sonnet 4"}]"#, )]; let value = build_list_models_from_rows(&rows, None, &[]); - let claude_entry = value["agent_types"] + let claude_entry = value["backends"] .as_array() .unwrap() .iter() - .find(|e| e["type"].as_str() == Some("claude")) + .find(|e| e["backend"].as_str() == Some("claude")) .expect("claude entry"); let models: Vec<&str> = claude_entry["models"] .as_array() @@ -813,11 +813,11 @@ mod tests { make_agent_row("codebuddy", true, r#"[{"id":"cb-pro","name":"Pro"}]"#), ]; let value = build_list_models_from_rows(&rows, Some("codebuddy"), &[]); - let types: Vec<&str> = value["agent_types"] + let types: Vec<&str> = value["backends"] .as_array() .unwrap() .iter() - .filter_map(|e| e["type"].as_str()) + .filter_map(|e| e["backend"].as_str()) .collect(); assert_eq!(types, vec!["codebuddy"]); } @@ -829,11 +829,11 @@ mod tests { make_agent_row_no_models("gemini", true), ]; let value = build_list_models_from_rows(&rows, None, &[]); - let types: Vec<&str> = value["agent_types"] + let types: Vec<&str> = value["backends"] .as_array() .unwrap() .iter() - .filter_map(|e| e["type"].as_str()) + .filter_map(|e| e["backend"].as_str()) .collect(); // gemini has no available_models in DB → should still appear but with empty models assert!(types.contains(&"gemini")); @@ -907,11 +907,11 @@ mod tests { aionrs_row, ]; let value = build_list_models_from_rows(&rows, None, &[]); - let types: Vec<&str> = value["agent_types"] + let types: Vec<&str> = value["backends"] .as_array() .unwrap() .iter() - .filter_map(|e| e["type"].as_str()) + .filter_map(|e| e["backend"].as_str()) .collect(); assert!(types.contains(&"claude")); assert!( @@ -934,11 +934,11 @@ mod tests { ]; // Filter by "aionrs" should only return aionrs let value = build_list_models_from_rows(&rows, Some("aionrs"), &[]); - let types: Vec<&str> = value["agent_types"] + let types: Vec<&str> = value["backends"] .as_array() .unwrap() .iter() - .filter_map(|e| e["type"].as_str()) + .filter_map(|e| e["backend"].as_str()) .collect(); assert_eq!(types, vec!["aionrs"]); } @@ -948,11 +948,11 @@ mod tests { let model_info_json = r#"{"current_model_id":"DeepSeek-V3.2","current_model_label":"DeepSeek-V3.2","available_models":[{"id":"GLM-5.0","label":"GLM-5.0"},{"id":"GLM-5.0-Turbo","label":"GLM-5.0-Turbo"},{"id":"DeepSeek-V3.2","label":"DeepSeek-V3.2"}]}"#; let rows = vec![make_agent_row("codebuddy", true, model_info_json)]; let value = build_list_models_from_rows(&rows, None, &[]); - let cb_entry = value["agent_types"] + let cb_entry = value["backends"] .as_array() .unwrap() .iter() - .find(|e| e["type"].as_str() == Some("codebuddy")) + .find(|e| e["backend"].as_str() == Some("codebuddy")) .expect("codebuddy entry"); let models: Vec<&str> = cb_entry["models"] .as_array() @@ -987,11 +987,11 @@ mod tests { aionrs_row, ]; let value = build_list_models_from_rows(&rows, None, &provider_models); - let aionrs_entry = value["agent_types"] + let aionrs_entry = value["backends"] .as_array() .unwrap() .iter() - .find(|e| e["type"].as_str() == Some("aionrs")) + .find(|e| e["backend"].as_str() == Some("aionrs")) .expect("aionrs entry"); let models: Vec<&str> = aionrs_entry["models"] .as_array() From 95e2278af900d7baca282eccff077dcaccd97e44 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 04:18:24 +0800 Subject: [PATCH 073/135] refactor(cron): derive backend from assistants --- crates/aionui-api-types/src/cron.rs | 15 +- crates/aionui-cron/src/service.rs | 191 ++++++++++-------- .../aionui-cron/tests/service_integration.rs | 34 +++- 3 files changed, 147 insertions(+), 93 deletions(-) diff --git a/crates/aionui-api-types/src/cron.rs b/crates/aionui-api-types/src/cron.rs index b8f95617a..2d1bc80db 100644 --- a/crates/aionui-api-types/src/cron.rs +++ b/crates/aionui-api-types/src/cron.rs @@ -64,7 +64,8 @@ pub struct CronAgentConfigDto { #[derive(Debug, Clone, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct CronAgentConfigWriteDto { - pub backend: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend: Option, pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub cli_path: Option, @@ -257,6 +258,18 @@ mod write_tests { let message = err.to_string(); assert!(message.contains("is_preset") || message.contains("preset_agent_type")); } + + #[test] + fn cron_agent_config_write_allows_missing_backend_when_assistant_id_present() { + let parsed = serde_json::from_value::(serde_json::json!({ + "name": "Helper", + "assistant_id": "assistant-1", + })) + .expect("assistant-backed writes should not require backend"); + + assert_eq!(parsed.assistant_id.as_deref(), Some("assistant-1")); + assert!(parsed.backend.is_none()); + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index cbf11367c..20b399f98 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -86,22 +86,13 @@ impl CronService { let created_by = CreatedBy::from_str(&req.created_by)?; let message = req.message.or(req.prompt).unwrap_or_default(); - let agent_config = req - .agent_config - .map(sanitize_agent_config_dto) - .map(|c| CronAgentConfig { - backend: c.backend, - name: c.name, - cli_path: c.cli_path, - is_preset: None, - assistant_id: c.assistant_id, - custom_agent_id: None, - preset_agent_type: None, - mode: c.mode, - model_id: c.model_id, - config_options: c.config_options, - workspace: c.workspace, - }); + let agent_config = match req.agent_config { + Some(config) => Some( + self.build_cron_agent_config(&resolved_agent_type, sanitize_agent_config_dto(config)) + .await?, + ), + None => None, + }; let now = now_ms(); let next_run_at = compute_next_run(&schedule, now); @@ -175,19 +166,7 @@ impl CronService { if let Some(config_dto) = &req.agent_config { let config_dto = sanitize_agent_config_dto(config_dto.clone()); validate_aionrs_agent_config(&job.agent_type, Some(&config_dto))?; - job.agent_config = Some(CronAgentConfig { - backend: config_dto.backend.clone(), - name: config_dto.name.clone(), - cli_path: config_dto.cli_path.clone(), - is_preset: None, - assistant_id: config_dto.assistant_id.clone(), - custom_agent_id: None, - preset_agent_type: None, - mode: config_dto.mode.clone(), - model_id: config_dto.model_id.clone(), - config_options: config_dto.config_options.clone(), - workspace: config_dto.workspace.clone(), - }); + job.agent_config = Some(self.build_cron_agent_config(&job.agent_type, config_dto).await?); } if let Some(title) = &req.conversation_title { job.conversation_title = Some(title.clone()); @@ -917,7 +896,7 @@ impl CronService { .full_auto_mode_id(Some(backend.as_str())) .to_owned(); let agent_config = aionui_api_types::CronAgentConfigWriteDto { - backend, + backend: Some(backend), name: get_string(&extra, &["agent_name", "agentName"]).unwrap_or_else(|| row.name.clone()), cli_path: get_string(&extra, &["cli_path", "cliPath"]).or_else(|| { extra @@ -943,6 +922,55 @@ impl CronService { (row.r#type.clone(), Some(agent_config)) } + async fn build_cron_agent_config( + &self, + runtime_agent_type: &str, + config: aionui_api_types::CronAgentConfigWriteDto, + ) -> Result { + let backend = if runtime_agent_type == "aionrs" { + config + .backend + .clone() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + CronError::InvalidAgentConfig("aionrs cron jobs require agent_config.backend (provider_id)".into()) + })? + } else if let Some(assistant_id) = config.assistant_id.as_deref() { + self.resolve_assistant_backend(Some(assistant_id)) + .await? + .or_else(|| config.backend.clone().filter(|value| !value.trim().is_empty())) + .ok_or_else(|| { + CronError::InvalidAgentConfig(format!( + "assistant '{assistant_id}' could not resolve a runtime backend" + )) + })? + } else { + config + .backend + .clone() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + CronError::InvalidAgentConfig( + "agent_config.backend is required when assistant_id is missing".into(), + ) + })? + }; + + Ok(CronAgentConfig { + backend, + name: config.name, + cli_path: config.cli_path, + is_preset: None, + assistant_id: config.assistant_id, + custom_agent_id: None, + preset_agent_type: None, + mode: config.mode, + model_id: config.model_id, + config_options: config.config_options, + workspace: config.workspace, + }) + } + async fn resolve_assistant_backend(&self, assistant_id: Option<&str>) -> Result, CronError> { let Some(assistant_id) = assistant_id.filter(|value| !value.is_empty()) else { return Ok(None); @@ -1188,7 +1216,7 @@ fn validate_aionrs_agent_config( if agent_type != "aionrs" { return Ok(()); } - let backend_ok = agent_config.is_some_and(|c| !c.backend.trim().is_empty()); + let backend_ok = agent_config.is_some_and(|c| c.backend.as_ref().is_some_and(|value| !value.trim().is_empty())); if !backend_ok { return Err(CronError::InvalidAgentConfig( "aionrs cron jobs require agent_config.backend (provider_id)".into(), @@ -1270,22 +1298,10 @@ fn build_update_params(job: &CronJob, req: &UpdateCronJobRequest) -> UpdateCronJ (None, None, None, None) }; - let agent_config = req.agent_config.as_ref().map(|c| { - let c = sanitize_agent_config_dto(c.clone()); - let config = CronAgentConfig { - backend: c.backend.clone(), - name: c.name.clone(), - cli_path: c.cli_path.clone(), - is_preset: None, - assistant_id: c.assistant_id.clone(), - custom_agent_id: None, - preset_agent_type: None, - mode: c.mode.clone(), - model_id: c.model_id.clone(), - config_options: c.config_options.clone(), - workspace: c.workspace.clone(), - }; - Some(serde_json::to_string(&config).unwrap_or_default()) + let agent_config = req.agent_config.as_ref().and_then(|_| { + job.agent_config + .as_ref() + .map(|config| Some(serde_json::to_string(config).unwrap_or_default())) }); UpdateCronJobParams { @@ -1319,6 +1335,14 @@ fn build_update_params(job: &CronJob, req: &UpdateCronJobRequest) -> UpdateCronJ fn sanitize_agent_config_dto( mut config: aionui_api_types::CronAgentConfigWriteDto, ) -> aionui_api_types::CronAgentConfigWriteDto { + if let Some(value) = config.backend.as_mut() { + let trimmed = value.trim().to_owned(); + if trimmed.is_empty() { + config.backend = None; + } else { + *value = trimmed; + } + } if let Some(value) = config.assistant_id.as_mut() { let trimmed = value.trim().to_owned(); if trimmed.is_empty() { @@ -1410,7 +1434,7 @@ mod tests { fn agent_cfg_dto(backend: &str) -> aionui_api_types::CronAgentConfigWriteDto { aionui_api_types::CronAgentConfigWriteDto { - backend: backend.to_owned(), + backend: Some(backend.to_owned()), name: "provider".into(), cli_path: None, assistant_id: None, @@ -1458,7 +1482,7 @@ mod tests { #[test] fn sanitize_agent_config_dto_clears_legacy_ids_when_assistant_id_present() { let config = aionui_api_types::CronAgentConfigWriteDto { - backend: "claude".into(), + backend: Some("claude".into()), name: "Helper".into(), cli_path: None, assistant_id: Some("assistant-1".into()), @@ -1474,8 +1498,8 @@ mod tests { } #[test] - fn sanitize_agent_config_dto_drops_legacy_custom_agent_id_without_assistant_id() { - let config = serde_json::from_value::(serde_json::json!({ + fn sanitize_agent_config_dto_rejects_legacy_custom_agent_id_without_assistant_id() { + let err = serde_json::from_value::(serde_json::json!({ "backend": "claude", "name": "Helper", "custom_agent_id": "legacy-assistant", @@ -1483,11 +1507,9 @@ mod tests { "mode": "default", "model_id": "claude-sonnet-4", })) - .expect("legacy fields should deserialize as ignored unknown fields"); - - let sanitized = sanitize_agent_config_dto(config); + .expect_err("legacy custom_agent_id must be rejected"); - assert!(sanitized.assistant_id.is_none()); + assert!(err.to_string().contains("custom_agent_id")); } // -- parse_execution_mode ------------------------------------------------- @@ -1603,7 +1625,20 @@ mod tests { #[test] fn build_update_params_strips_legacy_ids_when_assistant_id_present() { - let job = sample_job(); + let mut job = sample_job(); + job.agent_config = Some(CronAgentConfig { + backend: "claude".into(), + name: "Helper".into(), + cli_path: None, + is_preset: None, + assistant_id: Some("assistant-1".into()), + custom_agent_id: None, + preset_agent_type: None, + mode: Some("default".into()), + model_id: Some("claude-sonnet-4".into()), + config_options: None, + workspace: None, + }); let req = UpdateCronJobRequest { name: None, description: None, @@ -1612,7 +1647,7 @@ mod tests { message: None, execution_mode: None, agent_config: Some(aionui_api_types::CronAgentConfigWriteDto { - backend: "claude".into(), + backend: Some("claude".into()), name: "Helper".into(), cli_path: None, assistant_id: Some("assistant-1".into()), @@ -1636,38 +1671,18 @@ mod tests { } #[test] - fn build_update_params_drops_legacy_custom_agent_id_without_assistant_id() { - let job = sample_job(); - let req = UpdateCronJobRequest { - name: None, - description: None, - enabled: None, - schedule: None, - message: None, - execution_mode: None, - agent_config: Some( - serde_json::from_value::(serde_json::json!({ - "backend": "claude", - "name": "Helper", - "custom_agent_id": "legacy-assistant", - "preset_agent_type": "claude", - "mode": "default", - "model_id": "claude-sonnet-4", - })) - .expect("legacy fields should deserialize as ignored unknown fields"), - ), - conversation_title: None, - max_retries: None, - }; - - let params = build_update_params(&job, &req); - let config_json = params.agent_config.flatten().expect("agent config json"); - let config: CronAgentConfig = serde_json::from_str(&config_json).expect("parse cron config"); + fn build_update_params_rejects_legacy_custom_agent_id_without_assistant_id() { + let err = serde_json::from_value::(serde_json::json!({ + "backend": "claude", + "name": "Helper", + "custom_agent_id": "legacy-assistant", + "preset_agent_type": "claude", + "mode": "default", + "model_id": "claude-sonnet-4", + })) + .expect_err("legacy custom_agent_id must be rejected"); - assert!(config.assistant_id.is_none()); - assert!(config.custom_agent_id.is_none()); - assert!(config.preset_agent_type.is_none()); - assert!(config.is_preset.is_none()); + assert!(err.to_string().contains("custom_agent_id")); } #[test] diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index 5475c29b2..193c88022 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -898,7 +898,7 @@ async fn create_job_strips_legacy_agent_ids_when_assistant_id_present() { seed_assistant_definition(&definition_repo, "asstdef_assistant_1", "assistant-1", "claude").await; let mut req = make_create_req("Assistant Only Create", every_60s()); req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { - backend: "claude".into(), + backend: Some("claude".into()), name: "Helper".into(), cli_path: None, assistant_id: Some("assistant-1".into()), @@ -957,7 +957,7 @@ async fn create_job_derives_runtime_type_from_aionrs_assistant() { let mut req = make_create_req("Assistant Aionrs", every_60s()); req.agent_type = None; req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { - backend: "provider-gemini".into(), + backend: Some("provider-gemini".into()), name: "Aionrs Assistant".into(), cli_path: None, assistant_id: Some("assistant-aionrs".into()), @@ -987,7 +987,7 @@ async fn create_job_derives_runtime_type_from_assistant_overlay_override() { let mut req = make_create_req("Assistant Override", every_60s()); req.agent_type = None; req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { - backend: "provider-openai".into(), + backend: Some("provider-openai".into()), name: "Override Assistant".into(), cli_path: None, assistant_id: Some("assistant-override".into()), @@ -1002,6 +1002,32 @@ async fn create_job_derives_runtime_type_from_assistant_overlay_override() { assert_eq!(job.agent_type, "aionrs"); } +#[tokio::test] +async fn create_job_allows_assistant_backed_acp_jobs_without_backend_hint() { + let (svc, _, _, _, definition_repo, _) = setup_with_assistant_repos().await; + seed_assistant_definition(&definition_repo, "asstdef_assistant_2", "assistant-2", "claude").await; + + let mut req = make_create_req("Assistant Without Backend", every_60s()); + req.agent_type = None; + req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { + backend: None, + name: "Helper".into(), + cli_path: None, + assistant_id: Some("assistant-2".into()), + mode: Some("default".into()), + model_id: Some("claude-sonnet-4".into()), + config_options: None, + workspace: None, + }); + + let job = svc.add_job(req).await.unwrap(); + let config = job.agent_config.expect("agent config"); + + assert_eq!(job.agent_type, "acp"); + assert_eq!(config.assistant_id.as_deref(), Some("assistant-2")); + assert_eq!(config.backend, "claude"); +} + // ── CJ-2: Create three schedule types ────────────────────────────── #[tokio::test] @@ -1153,7 +1179,7 @@ async fn update_job_strips_legacy_agent_ids_when_assistant_id_present() { message: None, execution_mode: None, agent_config: Some(aionui_api_types::CronAgentConfigWriteDto { - backend: "claude".into(), + backend: Some("claude".into()), name: "Helper".into(), cli_path: None, assistant_id: Some("assistant-1".into()), From d1df34207d94c1e8bc9dc7ba5662f2a392c49815 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 05:32:48 +0800 Subject: [PATCH 074/135] refactor(team): ignore caller backend for assistants --- crates/aionui-team/src/provisioning.rs | 29 ++- .../tests/session_service_integration.rs | 177 ++++++++++++++++++ 2 files changed, 191 insertions(+), 15 deletions(-) diff --git a/crates/aionui-team/src/provisioning.rs b/crates/aionui-team/src/provisioning.rs index 2e2fcdb66..6e8847c93 100644 --- a/crates/aionui-team/src/provisioning.rs +++ b/crates/aionui-team/src/provisioning.rs @@ -271,27 +271,26 @@ impl TeamAgentProvisioner { requested_backend: Option<&str>, assistant_id: Option<&str>, ) -> Result { - let requested_backend = requested_backend.map(str::trim).filter(|value| !value.is_empty()); - if let Some(requested_backend) = requested_backend { - return Ok(requested_backend.to_owned()); + let assistant_key = assistant_id.map(str::trim).filter(|value| !value.is_empty()); + if let Some(assistant_key) = assistant_key { + let definition = self + .assistant_definition_repo + .get_by_key(assistant_key) + .await? + .ok_or_else(|| TeamError::InvalidRequest(format!("Preset assistant not found: {assistant_key}")))?; + let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; + return Ok(overlay + .and_then(|row| row.agent_backend_override) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(definition.agent_backend)); } - let Some(assistant_key) = assistant_id.map(str::trim).filter(|value| !value.is_empty()) else { + let Some(requested_backend) = requested_backend.map(str::trim).filter(|value| !value.is_empty()) else { return Err(TeamError::InvalidRequest( "backend is required when assistant_id is absent".into(), )); }; - - let definition = self - .assistant_definition_repo - .get_by_key(assistant_key) - .await? - .ok_or_else(|| TeamError::InvalidRequest(format!("Preset assistant not found: {assistant_key}")))?; - let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; - Ok(overlay - .and_then(|row| row.agent_backend_override) - .filter(|value| !value.trim().is_empty()) - .unwrap_or(definition.agent_backend)) + Ok(requested_backend.to_owned()) } pub(crate) async fn persist_spawned_agent( diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index fa2ffffe0..c59e14f29 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -1668,6 +1668,89 @@ async fn tc_create_team_derives_backend_from_assistant_when_backend_missing() { ); } +#[tokio::test] +async fn tc_create_team_ignores_requested_backend_when_assistant_id_present() { + let agent_metadata_repo: Arc = Arc::new(StubAgentMetadataRepo::empty()); + let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { + row: AssistantDefinitionRow { + definition_id: "def-team-lead".into(), + assistant_key: "assistant-lead".into(), + source: "user".into(), + owner_type: "user".into(), + source_ref: None, + source_version: None, + source_hash: None, + name: "Lead Assistant".into(), + name_i18n: "{}".into(), + description: None, + description_i18n: "{}".into(), + avatar_type: "emoji".into(), + avatar_value: Some("🤖".into()), + agent_backend: "claude".into(), + rule_resource_type: "inline".into(), + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]".into(), + recommended_prompts_i18n: "{}".into(), + default_model_mode: "auto".into(), + default_model_value: None, + default_permission_mode: "auto".into(), + default_permission_value: None, + default_skills_mode: "auto".into(), + default_skill_ids: "[]".into(), + custom_skill_names: "[]".into(), + default_disabled_builtin_skill_ids: "[]".into(), + default_mcps_mode: "auto".into(), + default_mcp_ids: "[]".into(), + created_at: 0, + updated_at: 0, + deleted_at: None, + }, + }); + let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { + row: AssistantOverlayRow { + definition_id: "def-team-lead".into(), + enabled: true, + sort_order: 0, + agent_backend_override: Some("codex".into()), + last_used_at: None, + created_at: 0, + updated_at: 0, + }, + }); + let (svc, _team_repo, _task_manager, conv_repo) = setup_with_factory_metadata_assistants_and_conversation_repo( + success_factory(), + agent_metadata_repo, + definition_repo, + overlay_repo, + ); + + let created = svc + .create_team( + "user1", + CreateTeamRequest { + name: "Assistant Lead".into(), + agents: vec![TeamAgentInput { + name: "Lead".into(), + role: "lead".into(), + backend: Some("gemini".into()), + model: "gpt-5".into(), + assistant_id: Some("assistant-lead".into()), + conversation_id: None, + }], + workspace: None, + }, + ) + .await + .unwrap(); + + assert_eq!(created.agents[0].backend, "codex"); + let extra = conv_repo + .get_extra(&created.agents[0].conversation_id) + .expect("lead conversation extra"); + assert_eq!(extra.get("backend").and_then(serde_json::Value::as_str), Some("codex")); +} + #[tokio::test] async fn ta_add_agent_uses_model_fallback_for_acp_backend() { let svc = setup_with_metadata_rows(vec![make_agent_metadata_row( @@ -1808,6 +1891,100 @@ async fn ta_add_agent_derives_backend_from_assistant_when_backend_missing() { assert_eq!(added.assistant_id.as_deref(), Some("assistant-worker")); } +#[tokio::test] +async fn ta_add_agent_ignores_requested_backend_when_assistant_id_present() { + let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { + row: AssistantDefinitionRow { + definition_id: "def-team-worker".into(), + assistant_key: "assistant-worker".into(), + source: "user".into(), + owner_type: "user".into(), + source_ref: None, + source_version: None, + source_hash: None, + name: "Worker Assistant".into(), + name_i18n: "{}".into(), + description: None, + description_i18n: "{}".into(), + avatar_type: "emoji".into(), + avatar_value: Some("🤖".into()), + agent_backend: "claude".into(), + rule_resource_type: "inline".into(), + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]".into(), + recommended_prompts_i18n: "{}".into(), + default_model_mode: "auto".into(), + default_model_value: None, + default_permission_mode: "auto".into(), + default_permission_value: None, + default_skills_mode: "auto".into(), + default_skill_ids: "[]".into(), + custom_skill_names: "[]".into(), + default_disabled_builtin_skill_ids: "[]".into(), + default_mcps_mode: "auto".into(), + default_mcp_ids: "[]".into(), + created_at: 0, + updated_at: 0, + deleted_at: None, + }, + }); + let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { + row: AssistantOverlayRow { + definition_id: "def-team-worker".into(), + enabled: true, + sort_order: 0, + agent_backend_override: Some("codex".into()), + last_used_at: None, + created_at: 0, + updated_at: 0, + }, + }); + let agent_metadata_repo: Arc = Arc::new(StubAgentMetadataRepo::empty()); + let (svc, _team_repo, _task_manager, _conv_repo) = setup_with_factory_metadata_assistants_and_conversation_repo( + success_factory(), + agent_metadata_repo, + definition_repo, + overlay_repo, + ); + + let team = svc + .create_team( + "user1", + CreateTeamRequest { + name: "T".into(), + agents: vec![TeamAgentInput { + name: "Lead".into(), + role: "lead".into(), + backend: Some("claude".into()), + model: "claude".into(), + assistant_id: None, + conversation_id: None, + }], + workspace: None, + }, + ) + .await + .unwrap(); + + let added = svc + .add_agent( + "user1", + &team.id, + AddAgentRequest { + name: "Worker".into(), + role: "teammate".into(), + backend: Some("gemini".into()), + model: "gpt-5".into(), + assistant_id: Some("assistant-worker".into()), + }, + ) + .await + .unwrap(); + + assert_eq!(added.backend, "codex"); +} + #[tokio::test] async fn tc2_create_single_agent_team() { let svc = setup(); From 9ce4833752be3de0248f1ef21a21b234ae8fc87e Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 05:55:46 +0800 Subject: [PATCH 075/135] refactor(channel): canonicalize legacy backend bindings --- crates/aionui-channel/src/channel_settings.rs | 149 ++++++++++++++---- 1 file changed, 117 insertions(+), 32 deletions(-) diff --git a/crates/aionui-channel/src/channel_settings.rs b/crates/aionui-channel/src/channel_settings.rs index fc7a7109d..05ae76316 100644 --- a/crates/aionui-channel/src/channel_settings.rs +++ b/crates/aionui-channel/src/channel_settings.rs @@ -162,8 +162,9 @@ impl ChannelSettingsService { for pref in prefs { if pref.key == key_agent { - assistant = - parse_channel_assistant_setting(&pref.value).map(normalize_channel_assistant_setting_for_response); + if let Some(parsed) = parse_channel_assistant_setting(&pref.value) { + assistant = Some(self.normalize_channel_assistant_setting_for_response(parsed).await?); + } } else if pref.key == key_model { default_model = parse_channel_model_setting(&pref.value); } @@ -187,7 +188,13 @@ impl ChannelSettingsService { return Ok(None); }; - Ok(parse_channel_assistant_setting(&pref.value).map(normalize_channel_assistant_setting_for_response)) + let parsed = if let Some(assistant) = parse_channel_assistant_setting(&pref.value) { + Some(self.normalize_channel_assistant_setting_for_response(assistant).await?) + } else { + None + }; + + Ok(parsed) } pub async fn set_assistant_setting( @@ -237,6 +244,70 @@ impl ChannelSettingsService { Ok(Some(ResolvedAgentConfig { agent_type, backend })) } + + async fn resolve_assistant_identity_for_legacy_binding( + &self, + assistant: &ChannelAssistantSettingResponse, + ) -> Result, ChannelError> { + let (Some(definition_repo), Some(_overlay_repo)) = + (&self.assistant_definition_repo, &self.assistant_overlay_repo) + else { + return Ok(None); + }; + + let legacy_backend = assistant + .backend + .as_deref() + .filter(|value| !value.trim().is_empty()) + .or_else(|| assistant.agent_type.as_deref().filter(|value| !value.trim().is_empty())); + + let Some(legacy_backend) = legacy_backend else { + return Ok(None); + }; + + let definitions = definition_repo.list().await?; + + Ok(definitions + .into_iter() + .find(|definition| definition.source == "generated" && definition.agent_backend == legacy_backend) + .map(|definition| definition.assistant_key)) + } + + async fn normalize_channel_assistant_setting_for_response( + &self, + assistant: ChannelAssistantSettingResponse, + ) -> Result { + let assistant_id = assistant + .assistant_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + assistant + .custom_agent_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + }); + + let canonical_assistant_id = if assistant_id.is_some() { + assistant_id + } else { + self.resolve_assistant_identity_for_legacy_binding(&assistant).await? + }; + + if canonical_assistant_id.is_some() { + Ok(ChannelAssistantSettingResponse { + assistant_id: canonical_assistant_id, + custom_agent_id: None, + backend: None, + agent_type: None, + name: assistant.name, + }) + } else { + Ok(assistant) + } + } } fn agent_key(platform: PluginType) -> String { @@ -288,35 +359,6 @@ fn normalize_channel_assistant_setting_for_write( } } -fn normalize_channel_assistant_setting_for_response( - assistant: ChannelAssistantSettingResponse, -) -> ChannelAssistantSettingResponse { - let assistant_id = assistant - .assistant_id - .as_deref() - .filter(|value| !value.trim().is_empty()) - .map(ToOwned::to_owned) - .or_else(|| { - assistant - .custom_agent_id - .as_deref() - .filter(|value| !value.trim().is_empty()) - .map(ToOwned::to_owned) - }); - - if assistant_id.is_some() { - ChannelAssistantSettingResponse { - assistant_id, - custom_agent_id: None, - backend: None, - agent_type: None, - name: assistant.name, - } - } else { - assistant - } -} - fn parse_channel_model_setting(value: &str) -> Option { let parsed: serde_json::Value = serde_json::from_str(value).ok()?; let id = parsed["id"].as_str()?.to_owned(); @@ -820,6 +862,27 @@ mod tests { assert_eq!(setting.name.as_deref(), Some("Codex")); } + #[tokio::test] + async fn get_assistant_setting_canonicalizes_backend_only_legacy_response_when_assistant_repos_exist() { + let repo = Arc::new(MockPrefRepo::with_data(vec![( + "assistant.lark.agent", + r#"{"backend":"codex","name":"Codex"}"#, + )])); + let definition_repo = Arc::new(MockAssistantDefinitionRepo { + rows: vec![make_definition("bare-codex", "codex")], + }); + let overlay_repo = Arc::new(MockAssistantOverlayRepo { rows: vec![] }); + let svc = ChannelSettingsService::new(repo).with_assistant_repos(definition_repo, overlay_repo); + + let setting = svc.get_assistant_setting(PluginType::Lark).await.unwrap().unwrap(); + + assert_eq!(setting.assistant_id.as_deref(), Some("bare-codex")); + assert!(setting.custom_agent_id.is_none()); + assert!(setting.backend.is_none()); + assert!(setting.agent_type.is_none()); + assert_eq!(setting.name.as_deref(), Some("Codex")); + } + #[tokio::test] async fn get_platform_settings_promotes_legacy_custom_agent_id_in_response() { let repo = Arc::new(MockPrefRepo::with_data(vec![( @@ -838,6 +901,28 @@ mod tests { assert_eq!(assistant.name.as_deref(), Some("Codex")); } + #[tokio::test] + async fn get_platform_settings_canonicalizes_backend_only_legacy_response_when_assistant_repos_exist() { + let repo = Arc::new(MockPrefRepo::with_data(vec![( + "assistant.telegram.agent", + r#"{"backend":"codex","name":"Codex"}"#, + )])); + let definition_repo = Arc::new(MockAssistantDefinitionRepo { + rows: vec![make_definition("bare-codex", "codex")], + }); + let overlay_repo = Arc::new(MockAssistantOverlayRepo { rows: vec![] }); + let svc = ChannelSettingsService::new(repo).with_assistant_repos(definition_repo, overlay_repo); + + let settings = svc.get_platform_settings(PluginType::Telegram).await.unwrap(); + let assistant = settings.assistant.expect("assistant settings"); + + assert_eq!(assistant.assistant_id.as_deref(), Some("bare-codex")); + assert!(assistant.custom_agent_id.is_none()); + assert!(assistant.backend.is_none()); + assert!(assistant.agent_type.is_none()); + assert_eq!(assistant.name.as_deref(), Some("Codex")); + } + // ── resolved_model_to_provider ──────────────────────────────────── #[test] From 273968d626bfe3cd9aef7595b89be53382426400 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 07:00:45 +0800 Subject: [PATCH 076/135] refactor(cron): require assistant ids for public create requests --- crates/aionui-api-types/src/cron.rs | 20 ++--- crates/aionui-app/tests/cron_e2e.rs | 77 ++++++++++++++----- crates/aionui-cron/src/service.rs | 29 +++---- .../aionui-cron/tests/service_integration.rs | 75 +++++++++++------- 4 files changed, 127 insertions(+), 74 deletions(-) diff --git a/crates/aionui-api-types/src/cron.rs b/crates/aionui-api-types/src/cron.rs index 2d1bc80db..0b209b4b5 100644 --- a/crates/aionui-api-types/src/cron.rs +++ b/crates/aionui-api-types/src/cron.rs @@ -145,6 +145,7 @@ pub struct CronJobResponse { // --------------------------------------------------------------------------- #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct CreateCronJobRequest { pub name: String, #[serde(default)] @@ -157,7 +158,6 @@ pub struct CreateCronJobRequest { pub conversation_id: String, #[serde(default)] pub conversation_title: Option, - pub agent_type: Option, pub created_by: String, #[serde(default)] pub execution_mode: Option, @@ -649,16 +649,14 @@ mod tests { "message": "Do the thing", "conversation_id": "conv_1", "conversation_title": "Tasks", - "agent_type": "acp", "created_by": "user", "execution_mode": "new_conversation", - "agent_config": {"backend": "acp", "name": "Claude"} + "agent_config": {"backend": "acp", "name": "Claude", "assistant_id": "assistant-1"} }); let req: CreateCronJobRequest = serde_json::from_value(raw).unwrap(); assert_eq!(req.name, "Daily task"); assert_eq!(req.message.as_deref(), Some("Do the thing")); assert_eq!(req.conversation_id, "conv_1"); - assert_eq!(req.agent_type.as_deref(), Some("acp")); assert_eq!(req.created_by, "user"); assert_eq!(req.execution_mode.as_deref(), Some("new_conversation")); assert!(req.agent_config.is_some()); @@ -670,7 +668,6 @@ mod tests { "name": "Ping", "schedule": {"kind": "every", "every_ms": 60000}, "conversation_id": "conv_1", - "agent_type": "acp", "created_by": "agent" }); let req: CreateCronJobRequest = serde_json::from_value(raw).unwrap(); @@ -679,7 +676,6 @@ mod tests { assert!(req.prompt.is_none()); assert!(req.execution_mode.is_none()); assert!(req.agent_config.is_none()); - assert_eq!(req.agent_type.as_deref(), Some("acp")); } #[test] @@ -689,7 +685,6 @@ mod tests { "schedule": {"kind": "at", "at_ms": 1000}, "prompt": "Do something", "conversation_id": "conv_1", - "agent_type": "gemini", "created_by": "user" }); let req: CreateCronJobRequest = serde_json::from_value(raw).unwrap(); @@ -702,7 +697,6 @@ mod tests { let raw = json!({ "schedule": {"kind": "every", "every_ms": 1000}, "conversation_id": "c1", - "agent_type": "acp", "created_by": "user" }); assert!(serde_json::from_value::(raw).is_err()); @@ -713,7 +707,6 @@ mod tests { let raw = json!({ "name": "X", "conversation_id": "c1", - "agent_type": "acp", "created_by": "user" }); assert!(serde_json::from_value::(raw).is_err()); @@ -724,22 +717,22 @@ mod tests { let raw = json!({ "name": "X", "schedule": {"kind": "every", "every_ms": 1000}, - "agent_type": "acp", "created_by": "user" }); assert!(serde_json::from_value::(raw).is_err()); } #[test] - fn create_request_missing_agent_type() { + fn create_request_rejects_legacy_agent_type() { let raw = json!({ "name": "X", "schedule": {"kind": "every", "every_ms": 1000}, "conversation_id": "c1", + "agent_type": "acp", "created_by": "user" }); - let req: CreateCronJobRequest = serde_json::from_value(raw).unwrap(); - assert!(req.agent_type.is_none()); + let err = serde_json::from_value::(raw).expect_err("legacy agent_type must be rejected"); + assert!(err.to_string().contains("agent_type")); } #[test] @@ -748,7 +741,6 @@ mod tests { "name": "X", "schedule": {"kind": "every", "every_ms": 1000}, "conversation_id": "c1", - "agent_type": "acp" }); assert!(serde_json::from_value::(raw).is_err()); } diff --git a/crates/aionui-app/tests/cron_e2e.rs b/crates/aionui-app/tests/cron_e2e.rs index 61fe95c4e..cb920f598 100644 --- a/crates/aionui-app/tests/cron_e2e.rs +++ b/crates/aionui-app/tests/cron_e2e.rs @@ -23,6 +23,15 @@ use common::{ // ── Helpers ────────────────────────────────────────────────────────── +const DEFAULT_CRON_ASSISTANT_ID: &str = "cron-e2e-assistant"; + +fn default_assistant_agent_config(name: &str) -> serde_json::Value { + json!({ + "name": name, + "assistant_id": DEFAULT_CRON_ASSISTANT_ID + }) +} + fn create_job_body(name: &str) -> serde_json::Value { json!({ "name": name, @@ -30,8 +39,8 @@ fn create_job_body(name: &str) -> serde_json::Value { "message": "test message", "conversation_id": "conv_1", "conversation_title": "Test Conv", - "agent_type": "acp", - "created_by": "user" + "created_by": "user", + "agent_config": default_assistant_agent_config(name) }) } @@ -41,8 +50,8 @@ fn create_at_job_body(name: &str, at_ms: i64) -> serde_json::Value { "schedule": { "kind": "at", "at_ms": at_ms, "description": "once" }, "message": "at message", "conversation_id": "conv_1", - "agent_type": "acp", - "created_by": "user" + "created_by": "user", + "agent_config": default_assistant_agent_config(name) }) } @@ -52,12 +61,33 @@ fn create_cron_job_body(name: &str, expr: &str) -> serde_json::Value { "schedule": { "kind": "cron", "expr": expr }, "message": "cron message", "conversation_id": "conv_1", - "agent_type": "acp", - "created_by": "user" + "created_by": "user", + "agent_config": default_assistant_agent_config(name) }) } +async fn ensure_default_assistant(app: &mut axum::Router, token: &str, csrf: &str) { + let req = json_with_token( + "POST", + "/api/assistants", + json!({ + "id": DEFAULT_CRON_ASSISTANT_ID, + "name": "Cron E2E Assistant", + "preset_agent_type": "claude" + }), + token, + csrf, + ); + let resp = app.clone().oneshot(req).await.unwrap(); + assert!( + resp.status() == StatusCode::CREATED || resp.status() == StatusCode::CONFLICT, + "expected assistant seed to be created or already exist, got {}", + resp.status() + ); +} + async fn create_job(app: &mut axum::Router, token: &str, csrf: &str, body: serde_json::Value) -> serde_json::Value { + ensure_default_assistant(app, token, csrf).await; let req = json_with_token("POST", "/api/cron/jobs", body, token, csrf); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); @@ -162,11 +192,12 @@ async fn cj2_create_three_schedule_types() { async fn cj3_create_missing_required_fields() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + ensure_default_assistant(&mut app, &token, &csrf).await; let invalid_bodies = vec![ - json!({"schedule": {"kind": "every", "every_ms": 60000}, "conversation_id": "c1", "agent_type": "acp", "created_by": "user"}), - json!({"name": "X", "conversation_id": "c1", "agent_type": "acp", "created_by": "user"}), - json!({"name": "X", "schedule": {"kind": "every", "every_ms": 60000}, "agent_type": "acp", "created_by": "user"}), + json!({"schedule": {"kind": "every", "every_ms": 60000}, "conversation_id": "c1", "created_by": "user", "agent_config": default_assistant_agent_config("X")}), + json!({"name": "X", "conversation_id": "c1", "created_by": "user", "agent_config": default_assistant_agent_config("X")}), + json!({"name": "X", "schedule": {"kind": "every", "every_ms": 60000}, "created_by": "user", "agent_config": default_assistant_agent_config("X")}), json!({"name": "X", "schedule": {"kind": "every", "every_ms": 60000}, "conversation_id": "c1", "created_by": "user"}), ]; @@ -185,6 +216,7 @@ async fn cj3_create_missing_required_fields() { async fn cj3b_create_accepts_workspace_with_whitespace_segment() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + ensure_default_assistant(&mut app, &token, &csrf).await; let dir = std::env::temp_dir().join(format!("aionui-cron-test-{}", aionui_common::generate_short_id())); std::fs::create_dir(&dir).unwrap(); let workspace = dir.join("Archive "); @@ -195,12 +227,12 @@ async fn cj3b_create_accepts_workspace_with_whitespace_segment() { "schedule": { "kind": "every", "every_ms": 60000, "description": "every minute" }, "message": "test message", "conversation_id": "", - "agent_type": "acp", "created_by": "user", "execution_mode": "new_conversation", "agent_config": { "backend": "acp", "name": "Cron Agent", + "assistant_id": DEFAULT_CRON_ASSISTANT_ID, "workspace": workspace.to_string_lossy() } }); @@ -218,18 +250,19 @@ async fn cj3b_create_accepts_workspace_with_whitespace_segment() { async fn cj3c_create_rejects_missing_workspace_path() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + ensure_default_assistant(&mut app, &token, &csrf).await; let body = json!({ "name": "Missing Workspace", "schedule": { "kind": "every", "every_ms": 60000, "description": "every minute" }, "message": "test message", "conversation_id": "", - "agent_type": "acp", "created_by": "user", "execution_mode": "new_conversation", "agent_config": { "backend": "claude", "name": "Claude Code", + "assistant_id": DEFAULT_CRON_ASSISTANT_ID, "workspace": "/tmp/cron-job-workspace-missing-path" } }); @@ -458,8 +491,8 @@ async fn cj9b_update_schedule_preserves_existing_timezone_when_omitted() { "schedule": { "kind": "cron", "expr": "0 0 9 * * *", "tz": "Asia/Shanghai" }, "message": "cron message", "conversation_id": "conv_1", - "agent_type": "acp", - "created_by": "user" + "created_by": "user", + "agent_config": default_assistant_agent_config("Schedule Change With Timezone") }), ) .await; @@ -674,7 +707,6 @@ async fn rn1c_run_now_new_conversation_preset_assistant_uses_fixed_assistant_mcp "schedule": { "kind": "every", "every_ms": 60000, "description": "every minute" }, "message": "cron preset assistant message", "conversation_id": "", - "agent_type": "acp", "created_by": "user", "execution_mode": "new_conversation", "agent_config": { @@ -728,7 +760,7 @@ async fn rn1c_run_now_new_conversation_preset_assistant_uses_fixed_assistant_mcp .expect("conversation should exist"); let extra: serde_json::Value = serde_json::from_str(&conversation.extra).expect("conversation extra should be valid json"); - assert_eq!(extra["assistant_id"], "u-fixed-mcp"); + assert!(extra.get("assistant_id").is_none()); assert!(extra.get("preset_assistant_id").is_none()); assert!(extra.get("custom_agent_id").is_none()); assert_eq!(extra["mcp_server_ids"], json!([fixed_mcp.id])); @@ -954,14 +986,15 @@ async fn sc5_invalid_cron_expression() { async fn sc6_cron_with_timezone() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + ensure_default_assistant(&mut app, &token, &csrf).await; let body = json!({ "name": "Shanghai Job", "schedule": { "kind": "cron", "expr": "0 0 9 * * *", "tz": "Asia/Shanghai" }, "message": "hello", "conversation_id": "conv_1", - "agent_type": "acp", - "created_by": "user" + "created_by": "user", + "agent_config": default_assistant_agent_config("Shanghai Job") }); let data = create_job(&mut app, &token, &csrf, body).await; @@ -975,14 +1008,15 @@ async fn sc6_cron_with_timezone() { async fn sc7_every_zero_interval() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + ensure_default_assistant(&mut app, &token, &csrf).await; let body = json!({ "name": "Zero Interval", "schedule": { "kind": "every", "every_ms": 0 }, "message": "x", "conversation_id": "conv_1", - "agent_type": "acp", - "created_by": "user" + "created_by": "user", + "agent_config": default_assistant_agent_config("Zero Interval") }); let req = json_with_token("POST", "/api/cron/jobs", body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); @@ -995,14 +1029,15 @@ async fn sc7_every_zero_interval() { async fn sc8_every_negative_interval() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + ensure_default_assistant(&mut app, &token, &csrf).await; let body = json!({ "name": "Negative Interval", "schedule": { "kind": "every", "every_ms": -1000 }, "message": "x", "conversation_id": "conv_1", - "agent_type": "acp", - "created_by": "user" + "created_by": "user", + "agent_config": default_assistant_agent_config("Negative Interval") }); let req = json_with_token("POST", "/api/cron/jobs", body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index 20b399f98..170c76902 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -74,11 +74,20 @@ impl CronService { // ----------------------------------------------------------------------- pub async fn add_job(&self, req: CreateCronJobRequest) -> Result { + self.add_job_internal(req, None).await + } + + async fn add_job_internal( + &self, + req: CreateCronJobRequest, + runtime_agent_type: Option, + ) -> Result { let schedule = schedule_from_dto(&req.schedule); validate_schedule(&schedule)?; - let resolved_agent_type = self - .resolve_new_job_agent_type(req.agent_type.as_deref(), req.agent_config.as_ref()) - .await?; + let resolved_agent_type = match runtime_agent_type { + 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())?; @@ -429,17 +438,12 @@ impl CronService { async fn resolve_new_job_agent_type( &self, - legacy_agent_type: Option<&str>, agent_config: Option<&aionui_api_types::CronAgentConfigWriteDto>, ) -> Result { let Some(assistant_id) = agent_config.and_then(|config| config.assistant_id.as_deref()) else { - let agent_type = legacy_agent_type - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - CronError::InvalidAgentConfig("agent_type is required when assistant_id is missing".into()) - })?; - return Ok(agent_type.to_owned()); + return Err(CronError::InvalidAgentConfig( + "assistant_id is required for new cron jobs".into(), + )); }; let definition = self @@ -1046,13 +1050,12 @@ impl aionui_conversation::response_middleware::ICronService for CronService { message: Some(params.message.clone()), conversation_id: conversation_id.to_owned(), conversation_title, - agent_type: Some(agent_type), created_by: "agent".to_owned(), execution_mode: Some("existing".to_owned()), agent_config, }; - match self.add_job(req).await { + match self.add_job_internal(req, Some(agent_type)).await { Ok(job) => { if let Err(err) = self .executor diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index 193c88022..a932f1c03 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -672,14 +672,22 @@ async fn setup_with_conv_repo() -> ( let emitter = CronEventEmitter::new(bc.clone() as Arc); let svc = CronService::new( cron_repo.clone(), - assistant_definition_repo, - assistant_overlay_repo, + assistant_definition_repo.clone(), + assistant_overlay_repo.clone(), scheduler, executor, emitter, data_dir, ); + seed_assistant_definition( + &assistant_definition_repo, + "asstdef_default", + "assistant-default", + "claude", + ) + .await; + std::mem::forget(db); (svc, cron_repo, bc, stub_conv_repo) } @@ -766,6 +774,14 @@ async fn setup_with_assistant_repos() -> ( data_dir, ); + seed_assistant_definition( + &assistant_definition_repo, + "asstdef_default", + "assistant-default", + "claude", + ) + .await; + std::mem::forget(db); ( svc, @@ -786,10 +802,18 @@ fn make_create_req(name: &str, schedule: CronScheduleDto) -> CreateCronJobReques message: Some("test message".into()), conversation_id: "conv_1".into(), conversation_title: Some("Test Conv".into()), - agent_type: Some("acp".into()), created_by: "user".into(), execution_mode: None, - agent_config: None, + agent_config: Some(aionui_api_types::CronAgentConfigWriteDto { + backend: Some("claude".into()), + name: "Default Assistant".into(), + cli_path: None, + assistant_id: Some("assistant-default".into()), + mode: Some("default".into()), + model_id: Some("claude-sonnet-4".into()), + config_options: None, + workspace: None, + }), } } @@ -918,35 +942,37 @@ async fn create_job_strips_legacy_agent_ids_when_assistant_id_present() { } #[tokio::test] -async fn create_job_rejects_deprecated_agent_types() { +async fn create_job_prefers_assistant_runtime_over_stale_backend_hint() { let (svc, _, _) = setup().await; - for agent_type in ["openclaw-gateway", "nanobot", "remote", "gemini", "codex"] { - let mut req = make_create_req(&format!("Deprecated {agent_type}"), every_60s()); - req.agent_type = Some(agent_type.to_owned()); - - let err = svc.add_job(req).await.unwrap_err(); - assert!(matches!(err, aionui_cron::error::CronError::InvalidAgentConfig(_))); - assert!( - err.to_string() - .contains("This agent type is no longer supported for new conversations."), - "unexpected error for {agent_type}: {err}" - ); - } + let mut req = make_create_req("Stale Backend Hint", every_60s()); + req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { + backend: Some("openclaw-gateway".into()), + 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()), + config_options: None, + workspace: None, + }); + + let job = svc.add_job(req).await.unwrap(); + let config = job.agent_config.expect("agent config"); + + assert_eq!(job.agent_type, "acp"); + assert_eq!(config.backend, "claude"); } #[tokio::test] -async fn create_job_requires_agent_type_when_assistant_id_is_missing() { +async fn create_job_requires_assistant_id_for_new_jobs() { let (svc, _, _) = setup().await; let mut req = make_create_req("Missing Runtime Type", every_60s()); - req.agent_type = None; + req.agent_config = None; let err = svc.add_job(req).await.unwrap_err(); assert!(matches!(err, aionui_cron::error::CronError::InvalidAgentConfig(_))); - assert!( - err.to_string() - .contains("agent_type is required when assistant_id is missing") - ); + assert!(err.to_string().contains("assistant_id is required for new cron jobs")); } #[tokio::test] @@ -955,7 +981,6 @@ async fn create_job_derives_runtime_type_from_aionrs_assistant() { seed_assistant_definition(&definition_repo, "asstdef_runtime_aionrs", "assistant-aionrs", "aionrs").await; let mut req = make_create_req("Assistant Aionrs", every_60s()); - req.agent_type = None; req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { backend: Some("provider-gemini".into()), name: "Aionrs Assistant".into(), @@ -985,7 +1010,6 @@ async fn create_job_derives_runtime_type_from_assistant_overlay_override() { seed_assistant_overlay(&overlay_repo, "asstdef_runtime_override", Some("aionrs")).await; let mut req = make_create_req("Assistant Override", every_60s()); - req.agent_type = None; req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { backend: Some("provider-openai".into()), name: "Override Assistant".into(), @@ -1008,7 +1032,6 @@ async fn create_job_allows_assistant_backed_acp_jobs_without_backend_hint() { seed_assistant_definition(&definition_repo, "asstdef_assistant_2", "assistant-2", "claude").await; let mut req = make_create_req("Assistant Without Backend", every_60s()); - req.agent_type = None; req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { backend: None, name: "Helper".into(), From 6144f3fd0ad1524f06d34752e8b25f6ee5d80076 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 07:25:45 +0800 Subject: [PATCH 077/135] refactor(team): reject legacy custom agent ids in write dtos --- crates/aionui-api-types/src/team.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/aionui-api-types/src/team.rs b/crates/aionui-api-types/src/team.rs index 520ededdd..415708ee6 100644 --- a/crates/aionui-api-types/src/team.rs +++ b/crates/aionui-api-types/src/team.rs @@ -15,13 +15,14 @@ use crate::TeamMcpStdioConfig; /// When `conversation_id` is supplied the existing conversation is adopted /// rather than creating a new one (single-chat → team-chat handoff). #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TeamAgentInput { pub name: String, pub role: String, #[serde(default)] pub backend: Option, pub model: String, - #[serde(default, alias = "custom_agent_id", alias = "customAgentId")] + #[serde(default)] pub assistant_id: Option, /// Adopt an existing conversation instead of creating a new one. /// When present the conversation's `extra` is updated with `teamId` @@ -67,6 +68,7 @@ pub struct AddAgentRequest { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct AddAgentRequestCompat { #[serde(default)] assistant: Option, @@ -78,7 +80,7 @@ struct AddAgentRequestCompat { backend: Option, #[serde(default)] model: Option, - #[serde(default, alias = "custom_agent_id", alias = "customAgentId")] + #[serde(default)] assistant_id: Option, } @@ -545,7 +547,7 @@ mod tests { } #[test] - fn deserialize_team_agent_input_promotes_legacy_custom_agent_id() { + fn deserialize_team_agent_input_rejects_legacy_custom_agent_id() { let raw = json!({ "name": "Lead", "role": "lead", @@ -553,8 +555,8 @@ mod tests { "model": "claude", "custom_agent_id": "assistant-legacy" }); - let input: TeamAgentInput = serde_json::from_value(raw).unwrap(); - assert_eq!(input.assistant_id.as_deref(), Some("assistant-legacy")); + let result = serde_json::from_value::(raw); + assert!(result.is_err()); } #[test] @@ -636,7 +638,7 @@ mod tests { } #[test] - fn deserialize_add_agent_request_with_custom_agent_id() { + fn deserialize_add_agent_request_rejects_custom_agent_id() { let raw = json!({ "name": "Custom", "role": "teammate", @@ -644,8 +646,8 @@ mod tests { "model": "claude", "custom_agent_id": "custom-1" }); - let req: AddAgentRequest = serde_json::from_value(raw).unwrap(); - assert_eq!(req.assistant_id.as_deref(), Some("custom-1")); + let result = serde_json::from_value::(raw); + assert!(result.is_err()); } #[test] From 98b718ae5e5e673803531815e6bfc87d586c37c2 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 08:14:45 +0800 Subject: [PATCH 078/135] refactor(team): require assistant ids for public writes --- crates/aionui-api-types/src/team.rs | 89 ++++++++++++++++++++++----- crates/aionui-app/tests/team_e2e.rs | 94 +++++++++++++++++++++++------ 2 files changed, 150 insertions(+), 33 deletions(-) diff --git a/crates/aionui-api-types/src/team.rs b/crates/aionui-api-types/src/team.rs index 415708ee6..4179a402e 100644 --- a/crates/aionui-api-types/src/team.rs +++ b/crates/aionui-api-types/src/team.rs @@ -14,23 +14,59 @@ use crate::TeamMcpStdioConfig; /// /// When `conversation_id` is supplied the existing conversation is adopted /// rather than creating a new one (single-chat → team-chat handoff). -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Clone)] pub struct TeamAgentInput { pub name: String, pub role: String, - #[serde(default)] pub backend: Option, pub model: String, - #[serde(default)] pub assistant_id: Option, /// Adopt an existing conversation instead of creating a new one. /// When present the conversation's `extra` is updated with `teamId` /// and `backend`; no new conversation row is written. + pub conversation_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct TeamAgentInputCompat { + #[serde(default)] + pub backend: Option, + #[serde(default)] + pub assistant_id: Option, + pub name: String, + pub role: String, + pub model: String, #[serde(default)] pub conversation_id: Option, } +fn normalize_assistant_id(assistant_id: Option) -> Option { + assistant_id + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) +} + +impl<'de> Deserialize<'de> for TeamAgentInput { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = TeamAgentInputCompat::deserialize(deserializer)?; + let assistant_id = + normalize_assistant_id(raw.assistant_id).ok_or_else(|| serde::de::Error::missing_field("assistant_id"))?; + + Ok(Self { + name: raw.name, + role: raw.role, + backend: raw.backend, + model: raw.model, + assistant_id: Some(assistant_id), + conversation_id: raw.conversation_id, + }) + } +} + /// Request body for `POST /api/teams`. /// /// Creates a team with the given name and agent list. @@ -103,13 +139,15 @@ impl<'de> Deserialize<'de> for AddAgentRequest { let name = raw.name.ok_or_else(|| serde::de::Error::missing_field("name"))?; let role = raw.role.ok_or_else(|| serde::de::Error::missing_field("role"))?; let model = raw.model.ok_or_else(|| serde::de::Error::missing_field("model"))?; + let assistant_id = + normalize_assistant_id(raw.assistant_id).ok_or_else(|| serde::de::Error::missing_field("assistant_id"))?; Ok(Self { name, role, backend: raw.backend, model, - assistant_id: raw.assistant_id, + assistant_id: Some(assistant_id), }) } } @@ -496,7 +534,8 @@ mod tests { "name": "Worker", "role": "teammate", "backend": "acp", - "model": "claude" + "model": "claude", + "assistant_id": "assistant-y" } ] }); @@ -509,7 +548,7 @@ mod tests { assert_eq!(req.agents[0].model, "claude"); assert_eq!(req.agents[0].assistant_id.as_deref(), Some("assistant-x")); assert_eq!(req.agents[1].name, "Worker"); - assert!(req.agents[1].assistant_id.is_none()); + assert_eq!(req.agents[1].assistant_id.as_deref(), Some("assistant-y")); } #[test] @@ -540,6 +579,7 @@ mod tests { "role": "lead", "backend": "acp", "model": "claude", + "assistant_id": "assistant-x", "conversation_id": "existing-conv-123" }); let input: TeamAgentInput = serde_json::from_value(raw).unwrap(); @@ -565,7 +605,8 @@ mod tests { "name": "Lead", "role": "lead", "backend": "acp", - "model": "claude" + "model": "claude", + "assistant_id": "assistant-x" }); let input: TeamAgentInput = serde_json::from_value(raw).unwrap(); assert!(input.conversation_id.is_none()); @@ -584,6 +625,18 @@ mod tests { assert_eq!(input.assistant_id.as_deref(), Some("assistant-x")); } + #[test] + fn deserialize_team_agent_input_requires_assistant_id() { + let raw = json!({ + "name": "Lead", + "role": "lead", + "backend": "acp", + "model": "claude" + }); + let result = serde_json::from_value::(raw); + assert!(result.is_err()); + } + #[test] fn deserialize_create_team_request_empty_agents() { let raw = json!({ "name": "Empty", "agents": [] }); @@ -627,14 +680,15 @@ mod tests { "name": "Helper", "role": "teammate", "backend": "acp", - "model": "claude" + "model": "claude", + "assistant_id": "assistant-1" }); let req: AddAgentRequest = serde_json::from_value(raw).unwrap(); assert_eq!(req.name, "Helper"); assert_eq!(req.role, "teammate"); assert_eq!(req.backend.as_deref(), Some("acp")); assert_eq!(req.model, "claude"); - assert!(req.assistant_id.is_none()); + assert_eq!(req.assistant_id.as_deref(), Some("assistant-1")); } #[test] @@ -683,16 +737,21 @@ mod tests { #[test] fn deserialize_add_agent_request_missing_name() { - let raw = json!({ "role": "teammate", "backend": "acp", "model": "claude" }); + let raw = json!({ + "role": "teammate", + "backend": "acp", + "model": "claude", + "assistant_id": "assistant-1" + }); let result = serde_json::from_value::(raw); assert!(result.is_err()); } #[test] - fn deserialize_add_agent_request_missing_backend() { - let raw = json!({ "name": "X", "role": "teammate", "model": "claude" }); - let req = serde_json::from_value::(raw).unwrap(); - assert!(req.backend.is_none()); + fn deserialize_add_agent_request_requires_assistant_id() { + let raw = json!({ "name": "X", "role": "teammate", "backend": "acp", "model": "claude" }); + let result = serde_json::from_value::(raw); + assert!(result.is_err()); } #[test] diff --git a/crates/aionui-app/tests/team_e2e.rs b/crates/aionui-app/tests/team_e2e.rs index 79f018a6b..56b4e0fc8 100644 --- a/crates/aionui-app/tests/team_e2e.rs +++ b/crates/aionui-app/tests/team_e2e.rs @@ -10,17 +10,49 @@ use common::{ setup_and_login, }; +const DEFAULT_TEAM_ASSISTANT_ID: &str = "team-e2e-assistant"; + +fn team_agent(name: &str, role: &str) -> serde_json::Value { + json!({ + "name": name, + "role": role, + "model": "claude", + "assistant_id": DEFAULT_TEAM_ASSISTANT_ID + }) +} + fn two_agent_body() -> serde_json::Value { json!({ "name": "Alpha", "agents": [ - { "name": "Lead", "role": "lead", "backend": "acp", "model": "claude" }, - { "name": "Worker", "role": "teammate", "backend": "acp", "model": "claude" } + team_agent("Lead", "lead"), + team_agent("Worker", "teammate") ] }) } +async fn ensure_default_team_assistant(app: &mut axum::Router, token: &str, csrf: &str) { + let req = json_with_token( + "POST", + "/api/assistants", + json!({ + "id": DEFAULT_TEAM_ASSISTANT_ID, + "name": "Team E2E Assistant", + "preset_agent_type": "claude" + }), + token, + csrf, + ); + let resp = app.clone().oneshot(req).await.unwrap(); + assert!( + resp.status() == StatusCode::CREATED || resp.status() == StatusCode::CONFLICT, + "expected team assistant seed to be created or already exist, got {}", + resp.status() + ); +} + async fn create_team(app: &mut axum::Router, token: &str, csrf: &str) -> serde_json::Value { + ensure_default_team_assistant(app, token, csrf).await; let req = json_with_token("POST", "/api/teams", two_agent_body(), token, csrf); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); @@ -53,10 +85,11 @@ async fn tc1_create_team_with_multiple_agents() { async fn tc2_create_single_agent_team() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + ensure_default_team_assistant(&mut app, &token, &csrf).await; let body = json!({ "name": "Solo", - "agents": [{ "name": "Lead", "role": "lead", "backend": "acp", "model": "claude" }] + "agents": [team_agent("Lead", "lead")] }); let req = json_with_token("POST", "/api/teams", body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); @@ -97,8 +130,8 @@ async fn tc3b_create_team_writes_legacy_extra_shape() { assert_eq!(extra["teamId"], data["id"]); assert!(extra["slot_id"].as_str().is_some_and(|s| !s.is_empty())); assert_eq!(extra["role"], "lead"); - assert_eq!(extra["backend"], "acp"); - assert_eq!(extra["session_mode"], "yolo"); + assert_eq!(extra["backend"], "claude"); + assert_eq!(extra["session_mode"], "bypassPermissions"); assert_eq!(extra["current_model_id"], "claude"); } @@ -107,12 +140,13 @@ async fn tc3b_create_team_writes_legacy_extra_shape() { async fn tc4_first_agent_is_lead() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + ensure_default_team_assistant(&mut app, &token, &csrf).await; let body = json!({ "name": "T", "agents": [ - { "name": "A", "role": "teammate", "backend": "acp", "model": "claude" }, - { "name": "B", "role": "teammate", "backend": "acp", "model": "claude" } + team_agent("A", "teammate"), + team_agent("B", "teammate") ] }); let req = json_with_token("POST", "/api/teams", body, &token, &csrf); @@ -139,8 +173,14 @@ async fn tc5_empty_agents_returns_error() { async fn tc6_missing_name_returns_error() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; - - let body = json!({ "agents": [{ "name": "L", "role": "lead", "backend": "acp", "model": "c" }] }); + ensure_default_team_assistant(&mut app, &token, &csrf).await; + + let body = json!({ "agents": [json!({ + "name": "L", + "role": "lead", + "model": "c", + "assistant_id": DEFAULT_TEAM_ASSISTANT_ID + })] }); let req = json_with_token("POST", "/api/teams", body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); @@ -150,6 +190,7 @@ async fn tc6_missing_name_returns_error() { async fn tc6b_workspace_with_whitespace_segment_is_accepted() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + ensure_default_team_assistant(&mut app, &token, &csrf).await; let temp = tempfile::tempdir().unwrap(); let workspace = temp.path().join("Archive "); std::fs::create_dir_all(&workspace).unwrap(); @@ -157,7 +198,7 @@ async fn tc6b_workspace_with_whitespace_segment_is_accepted() { let body = json!({ "name": "Alpha", "workspace": workspace.to_string_lossy(), - "agents": [{ "name": "Lead", "role": "lead", "backend": "acp", "model": "claude" }] + "agents": [team_agent("Lead", "lead")] }); let req = json_with_token("POST", "/api/teams", body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); @@ -171,13 +212,14 @@ async fn tc6b_workspace_with_whitespace_segment_is_accepted() { async fn tc6c_create_team_rejects_missing_workspace_path() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + ensure_default_team_assistant(&mut app, &token, &csrf).await; let missing_workspace = std::env::temp_dir().join(format!("aionui-team-missing-{}", aionui_common::generate_short_id())); let body = json!({ "name": "Alpha", "workspace": missing_workspace.to_string_lossy(), - "agents": [{ "name": "Lead", "role": "lead", "backend": "acp", "model": "claude" }] + "agents": [team_agent("Lead", "lead")] }); let req = json_with_token("POST", "/api/teams", body, &token, &csrf); let resp = app.clone().oneshot(req).await.unwrap(); @@ -237,10 +279,11 @@ async fn tl2_list_multiple_teams() { let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; create_team(&mut app, &token, &csrf).await; + ensure_default_team_assistant(&mut app, &token, &csrf).await; let body = json!({ "name": "Beta", - "agents": [{ "name": "Lead", "role": "lead", "backend": "acp", "model": "claude" }] + "agents": [team_agent("Lead", "lead")] }); let req = json_with_token("POST", "/api/teams", body, &token, &csrf); let resp = app.clone().oneshot(req).await.unwrap(); @@ -514,8 +557,8 @@ async fn aa1_add_agent_to_team() { let body = json!({ "name": "New Agent", "role": "teammate", - "backend": "acp", - "model": "claude" + "model": "claude", + "assistant_id": DEFAULT_TEAM_ASSISTANT_ID }); let req = json_with_token("POST", &format!("/api/teams/{team_id}/agents"), body, &token, &csrf); let resp = app.clone().oneshot(req).await.unwrap(); @@ -534,7 +577,12 @@ async fn aa2_add_agent_increases_count() { let data = create_team(&mut app, &token, &csrf).await; let team_id = data["id"].as_str().unwrap(); - let body = json!({ "name": "X", "role": "teammate", "backend": "acp", "model": "claude" }); + let body = json!({ + "name": "X", + "role": "teammate", + "model": "claude", + "assistant_id": DEFAULT_TEAM_ASSISTANT_ID + }); let req = json_with_token("POST", &format!("/api/teams/{team_id}/agents"), body, &token, &csrf); app.clone().oneshot(req).await.unwrap(); @@ -550,7 +598,12 @@ async fn aa4_add_agent_nonexistent_team() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; - let body = json!({ "name": "X", "role": "teammate", "backend": "acp", "model": "claude" }); + let body = json!({ + "name": "X", + "role": "teammate", + "model": "claude", + "assistant_id": DEFAULT_TEAM_ASSISTANT_ID + }); let req = json_with_token("POST", "/api/teams/nonexistent/agents", body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); @@ -565,7 +618,7 @@ async fn aa5_add_agent_missing_fields() { let data = create_team(&mut app, &token, &csrf).await; let team_id = data["id"].as_str().unwrap(); - let body = json!({ "role": "teammate", "backend": "acp" }); + let body = json!({ "role": "teammate" }); let req = json_with_token("POST", &format!("/api/teams/{team_id}/agents"), body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); @@ -980,7 +1033,12 @@ async fn full_team_lifecycle() { assert_eq!(data["agents"].as_array().unwrap().len(), 2); // Add agent - let body = json!({ "name": "Helper", "role": "teammate", "backend": "acp", "model": "claude" }); + let body = json!({ + "name": "Helper", + "role": "teammate", + "model": "claude", + "assistant_id": DEFAULT_TEAM_ASSISTANT_ID + }); let req = json_with_token("POST", &format!("/api/teams/{team_id}/agents"), body, &token, &csrf); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); From 0e7fcc4aaf0a8ccd5dc764eb76dab053057e76f1 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 08:59:40 +0800 Subject: [PATCH 079/135] refactor(agent): drop legacy public agents catalog --- crates/aionui-ai-agent/src/registry.rs | 22 ++++----- crates/aionui-ai-agent/src/routes/agent.rs | 20 +++----- crates/aionui-ai-agent/src/services/agent.rs | 10 ---- .../aionui-api-types/src/agent_discovery.rs | 5 +- crates/aionui-app/tests/acp_e2e.rs | 28 +++++++---- .../aionui-app/tests/agent_integration_e2e.rs | 46 ++++++++----------- crates/aionui-app/tests/custom_agent_e2e.rs | 14 +++--- 7 files changed, 65 insertions(+), 80 deletions(-) diff --git a/crates/aionui-ai-agent/src/registry.rs b/crates/aionui-ai-agent/src/registry.rs index 53f878640..e3e221c0f 100644 --- a/crates/aionui-ai-agent/src/registry.rs +++ b/crates/aionui-ai-agent/src/registry.rs @@ -239,12 +239,12 @@ impl AgentRegistry { rows } - /// Snapshot of every row the caller is expected to see — rows - /// that are user-disabled (`enabled = 0`) or whose spawn command - /// could not be located on `$PATH` (`available = false`) are - /// filtered out. `/api/agents` feeds the frontend pill bar, which - /// would otherwise render unusable vendor chips that fail the - /// moment the user tries to spawn them. + /// Snapshot of every visible row — rows that are user-disabled + /// (`enabled = 0`) or whose spawn command could not be located on + /// `$PATH` (`available = false`) are filtered out. Callers that + /// still need a legacy "available agents only" read model (for + /// example refresh responses) should use this rather than the + /// diagnostics-first management list. pub async fn list_all(&self) -> Vec { let mut rows: Vec = self .by_id @@ -357,11 +357,11 @@ impl AgentRegistry { } } -/// A catalog row is visible to conversation callers when the user has -/// it enabled, the spawn command was resolved at hydrate/refresh time, -/// and the latest known availability snapshot does not already mark it -/// unavailable. This keeps both uninstalled CLIs and rows that most -/// recently failed ACP/session admission out of `/api/agents`. +/// A catalog row is visible when the user has it enabled, the spawn +/// command was resolved at hydrate/refresh time, and the latest known +/// availability snapshot does not already mark it unavailable. This +/// keeps both uninstalled CLIs and rows that most recently failed +/// ACP/session admission out of visible legacy catalog reads. fn is_visible(meta: &AgentMetadata) -> bool { meta.enabled && matches!(derive_management_status(meta), AgentManagementStatus::Available) } diff --git a/crates/aionui-ai-agent/src/routes/agent.rs b/crates/aionui-ai-agent/src/routes/agent.rs index fe6167696..677c67141 100644 --- a/crates/aionui-ai-agent/src/routes/agent.rs +++ b/crates/aionui-ai-agent/src/routes/agent.rs @@ -4,9 +4,9 @@ //! //! Endpoints: //! -//! - `GET /api/agents` — list available agents +//! - `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/test` — test custom agent configuration (e.g. LLM connection) +//! - `POST /api/agents/custom/try-connect` — test custom agent configuration (e.g. ACP connection) use axum::Router; use axum::extract::rejection::JsonRejection; @@ -26,7 +26,6 @@ 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)) @@ -39,15 +38,6 @@ 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, @@ -62,7 +52,11 @@ async fn list_agent_logos( Extension(_user): Extension, ) -> Result>>, ApiError> { Ok(Json(ApiResponse::ok( - state.service.list_agent_logos().await.map_err(agent_error_to_api_error)?, + state + .service + .list_agent_logos() + .await + .map_err(agent_error_to_api_error)?, ))) } diff --git a/crates/aionui-ai-agent/src/services/agent.rs b/crates/aionui-ai-agent/src/services/agent.rs index 3962ccb21..d13c90b56 100644 --- a/crates/aionui-ai-agent/src/services/agent.rs +++ b/crates/aionui-ai-agent/src/services/agent.rs @@ -80,16 +80,6 @@ impl AgentService { // Agent operations impl AgentService { - 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 diff --git a/crates/aionui-api-types/src/agent_discovery.rs b/crates/aionui-api-types/src/agent_discovery.rs index 028084065..49aa3e7f1 100644 --- a/crates/aionui-api-types/src/agent_discovery.rs +++ b/crates/aionui-api-types/src/agent_discovery.rs @@ -142,8 +142,9 @@ pub struct AgentLogoEntry { /// The unified, decoded view of an `agent_metadata` row. /// -/// Also the API response shape: `/api/agents` returns a list of these -/// directly, no adapter required. +/// This remains the refresh/logos/custom-agent CRUD read model for the +/// legacy agent catalog, even though business surfaces now consume +/// assistants instead of `GET /api/agents`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentMetadata { pub id: String, diff --git a/crates/aionui-app/tests/acp_e2e.rs b/crates/aionui-app/tests/acp_e2e.rs index f5a915d53..47bae804f 100644 --- a/crates/aionui-app/tests/acp_e2e.rs +++ b/crates/aionui-app/tests/acp_e2e.rs @@ -19,11 +19,11 @@ use common::{body_json, build_app, get_with_token, json_with_token, setup_and_lo // ── Global ACP routes ──────────────────────────────────────────── #[tokio::test] -async fn list_agents_returns_array() { +async fn management_list_returns_array() { let (mut app, services) = build_app().await; let (token, _csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; - let req = get_with_token("/api/agents", &token); + let req = get_with_token("/api/agents/management", &token); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); @@ -122,7 +122,7 @@ async fn management_list_includes_missing_custom_agents() { } #[tokio::test] -async fn list_agents_hides_rows_with_unavailable_snapshot() { +async fn management_list_marks_rows_with_unavailable_snapshot() { let (mut app, services) = build_app().await; let (token, _csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; @@ -174,17 +174,27 @@ async fn list_agents_hides_rows_with_unavailable_snapshot() { .unwrap(); services.agent_registry.invalidate_and_rehydrate().await.unwrap(); - let req = get_with_token("/api/agents", &token); + let req = get_with_token("/api/agents/management", &token); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; let rows = body["data"].as_array().expect("data should be an array"); - assert!( - rows.iter() - .all(|item| item["id"].as_str() != Some("custom-unavailable-agent")), - "rows with last_check_status=unavailable should stay out of /api/agents" - ); + let row = rows + .iter() + .find(|item| item["id"].as_str() == Some("custom-unavailable-agent")) + .expect("management list should include unavailable rows"); + assert_eq!(row["status"], "unavailable"); +} + +#[tokio::test] +async fn legacy_agents_endpoint_is_not_found() { + let (mut app, services) = build_app().await; + let (token, _csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await; + + let req = get_with_token("/api/agents", &token); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] diff --git a/crates/aionui-app/tests/agent_integration_e2e.rs b/crates/aionui-app/tests/agent_integration_e2e.rs index 23dab3e2f..cf70aff89 100644 --- a/crates/aionui-app/tests/agent_integration_e2e.rs +++ b/crates/aionui-app/tests/agent_integration_e2e.rs @@ -250,7 +250,7 @@ async fn upsert_visible_agent_metadata(services: &aionui_app::AppServices, id: & // ── Agent catalog tests ───────────────────────────────────────── #[tokio::test] -async fn agents_endpoint_hides_deprecated_runtime_rows() { +async fn management_endpoint_keeps_deprecated_runtime_rows_for_diagnostics() { let (mut app, services, _mock_tm) = build_app_with_mock_tasks().await; let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "Pass123!").await; @@ -266,7 +266,7 @@ async fn agents_endpoint_hides_deprecated_runtime_rows() { } services.agent_registry.invalidate_and_rehydrate().await.unwrap(); - let req = get_with_token("/api/agents", &token); + let req = get_with_token("/api/agents/management", &token); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); @@ -276,14 +276,14 @@ async fn agents_endpoint_hides_deprecated_runtime_rows() { assert!(types.contains(&"acp")); assert!(types.contains(&"aionrs")); - assert!(!types.contains(&"openclaw-gateway")); - assert!(!types.contains(&"nanobot")); - assert!(!types.contains(&"remote")); - assert!(!types.contains(&"gemini")); + assert!(types.contains(&"openclaw-gateway")); + assert!(types.contains(&"nanobot")); + assert!(types.contains(&"remote")); + assert!(types.contains(&"gemini")); } #[tokio::test] -async fn agents_endpoint_handles_openclaw_as_acp_backend() { +async fn management_endpoint_handles_openclaw_as_acp_backend() { let (mut app, services, _mock_tm) = build_app_with_mock_tasks().await; let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "Pass123!").await; @@ -298,7 +298,7 @@ async fn agents_endpoint_handles_openclaw_as_acp_backend() { assert_eq!(meta.args, vec!["acp"]); assert_eq!(meta.agent_source, AgentSource::Builtin); - let req = get_with_token("/api/agents", &token); + let req = get_with_token("/api/agents/management", &token); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); @@ -307,25 +307,12 @@ async fn agents_endpoint_handles_openclaw_as_acp_backend() { let openclaw = agents .iter() - .find(|agent| agent["backend"].as_str() == Some("openclaw")); - if meta.available { - let openclaw = openclaw.expect("available OpenClaw ACP should be visible from /api/agents"); - assert_eq!(openclaw["agent_type"], "acp"); - assert_eq!(openclaw["command"], "openclaw"); - assert_eq!(openclaw["args"], json!(["acp"])); - } else { - assert!( - openclaw.is_none(), - "unavailable OpenClaw ACP should be hidden from /api/agents" - ); - } - - assert!( - agents - .iter() - .all(|agent| agent["agent_type"].as_str() != Some("openclaw-gateway")), - "old openclaw-gateway row must remain hidden from new conversation catalog" - ); + .find(|agent| agent["backend"].as_str() == Some("openclaw")) + .expect("OpenClaw ACP row should be visible from /api/agents/management"); + assert!(meta.available || openclaw["status"] != "available"); + assert_eq!(openclaw["agent_type"], "acp"); + assert_eq!(openclaw["command"], "openclaw"); + assert_eq!(openclaw["args"], json!(["acp"])); } #[tokio::test] @@ -365,7 +352,10 @@ async fn agent_logos_endpoint_returns_backend_to_logo_catalog() { let logo = entry["logo"].as_str().expect("logo present"); assert!(!backend.is_empty(), "backend must not be empty"); assert!(!logo.is_empty(), "logo must not be empty"); - assert!(seen.insert(backend.to_owned()), "backend {backend} duplicated in catalog"); + assert!( + seen.insert(backend.to_owned()), + "backend {backend} duplicated in catalog" + ); } } diff --git a/crates/aionui-app/tests/custom_agent_e2e.rs b/crates/aionui-app/tests/custom_agent_e2e.rs index b8c2f3995..f105237c0 100644 --- a/crates/aionui-app/tests/custom_agent_e2e.rs +++ b/crates/aionui-app/tests/custom_agent_e2e.rs @@ -56,8 +56,8 @@ async fn create_agent(app: &mut axum::Router, token: &str, csrf: &str, body: Val (status, json) } -async fn list_agents(app: &mut axum::Router, token: &str) -> Value { - let req = get_with_token("/api/agents", token); +async fn list_management_agents(app: &mut axum::Router, token: &str) -> Value { + let req = get_with_token("/api/agents/management", token); let resp = app.clone().oneshot(req).await.unwrap(); body_json(resp).await } @@ -98,11 +98,11 @@ async fn custom_agent_full_roundtrip() { assert_eq!(json["data"]["icon"], "🤖"); // List — agent should be visible - let listed = list_agents(&mut app, &token).await; + let listed = list_management_agents(&mut app, &token).await; let agents = listed["data"].as_array().expect("array"); assert!( agents.iter().any(|a| a["id"] == id), - "newly created agent should appear in GET /api/agents" + "newly created agent should appear in GET /api/agents/management" ); // Update — keep "sh" so the row stays available after rehydrate. @@ -164,11 +164,11 @@ async fn custom_agent_full_roundtrip() { assert_eq!(json["data"]["deleted"], true); // Post-delete list must not contain the id - let listed = list_agents(&mut app, &token).await; + let listed = list_management_agents(&mut app, &token).await; let agents = listed["data"].as_array().unwrap(); assert!( agents.iter().all(|a| a["id"] != id), - "deleted agent should disappear from GET /api/agents" + "deleted agent should disappear from GET /api/agents/management" ); unsafe { @@ -356,7 +356,7 @@ async fn test_on_save_cli_not_found_blocks_upsert() { ); // DB must not have the row. - let listed = list_agents(&mut app, &token).await; + let listed = list_management_agents(&mut app, &token).await; let agents = listed["data"].as_array().unwrap(); assert!( agents.iter().all(|a| a["name"] != "bad"), From ca65c80a6c1ab9a557bbfa487b3937c6b4cadaee Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 10:29:58 +0800 Subject: [PATCH 080/135] refactor(team): prefer assistant avatars in responses --- .../src/service/response_builder.rs | 31 ++++- .../tests/session_service_integration.rs | 116 +++++++++++++++--- 2 files changed, 125 insertions(+), 22 deletions(-) diff --git a/crates/aionui-team/src/service/response_builder.rs b/crates/aionui-team/src/service/response_builder.rs index 42cf31a1f..a789d9f82 100644 --- a/crates/aionui-team/src/service/response_builder.rs +++ b/crates/aionui-team/src/service/response_builder.rs @@ -37,10 +37,14 @@ impl TeamSessionService { async fn resolve_agent_icon(&self, agent: &TeamAgent) -> Result, TeamError> { if let Some(assistant_id) = agent.assistant_id.as_deref() - && let Some(row) = self.agent_metadata_repo.get(assistant_id).await? - && row.icon.is_some() + && let Some(definition) = self.assistant_definition_repo.get_by_key(assistant_id).await? + && let Some(icon) = assistant_icon( + definition.assistant_key.as_str(), + &definition.avatar_type, + definition.avatar_value.as_deref(), + ) { - return Ok(row.icon); + return Ok(Some(icon)); } if let Some(row) = self @@ -64,3 +68,24 @@ impl TeamSessionService { Ok(None) } } + +fn assistant_icon(assistant_key: &str, avatar_type: &str, avatar_value: Option<&str>) -> Option { + match avatar_type { + "builtin_asset" | "user_asset" => avatar_value.map(|value| { + if is_direct_avatar_url(value) { + value.to_string() + } else { + format!("/api/assistants/{assistant_key}/avatar") + } + }), + _ => None, + } +} + +fn is_direct_avatar_url(value: &str) -> bool { + value.starts_with("http://") + || value.starts_with("https://") + || value.starts_with("data:") + || value.starts_with("file://") + || value.starts_with("/api/assistants/") +} diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index c59e14f29..948dc6dc1 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -1508,12 +1508,55 @@ async fn create_team_without_workspace_uses_leader_auto_workspace_for_all_initia } #[tokio::test] -async fn tc_create_team_uses_assistant_id_icon_lookup() { - let svc = setup_with_metadata_rows(vec![make_agent_metadata_row( - "2d23ff1c", - "claude", - "/api/assets/logos/ai-major/claude.svg", - )]); +async fn tc_create_team_prefers_assistant_avatar_over_backend_logo() { + let agent_metadata_repo: Arc = + Arc::new(StubAgentMetadataRepo::with_rows(vec![make_agent_metadata_row( + "builtin-claude", + "claude", + "/api/assets/logos/ai-major/claude.svg", + )])); + let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { + row: AssistantDefinitionRow { + definition_id: "def-team-lead".into(), + assistant_key: "assistant-lead".into(), + source: "builtin".into(), + owner_type: "system".into(), + source_ref: Some("assistant-lead".into()), + source_version: None, + source_hash: None, + name: "Lead Assistant".into(), + name_i18n: "{}".into(), + description: None, + description_i18n: "{}".into(), + avatar_type: "builtin_asset".into(), + avatar_value: Some("avatars/assistant-lead.png".into()), + agent_backend: "claude".into(), + rule_resource_type: "inline".into(), + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]".into(), + recommended_prompts_i18n: "{}".into(), + default_model_mode: "auto".into(), + default_model_value: None, + default_permission_mode: "auto".into(), + default_permission_value: None, + default_skills_mode: "auto".into(), + default_skill_ids: "[]".into(), + custom_skill_names: "[]".into(), + default_disabled_builtin_skill_ids: "[]".into(), + default_mcps_mode: "auto".into(), + default_mcp_ids: "[]".into(), + created_at: 0, + updated_at: 0, + deleted_at: None, + }, + }); + let (svc, _team_repo, _task_manager, _conv_repo) = setup_with_factory_metadata_assistants_and_conversation_repo( + success_factory(), + agent_metadata_repo, + definition_repo, + Arc::new(EmptyAssistantOverlayRepo), + ); let resp = svc .create_team( @@ -1523,9 +1566,9 @@ async fn tc_create_team_uses_assistant_id_icon_lookup() { agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: Some("acp".into()), + backend: Some("claude".into()), model: "claude".into(), - assistant_id: Some("2d23ff1c".into()), + assistant_id: Some("assistant-lead".into()), conversation_id: None, }], workspace: None, @@ -1536,20 +1579,55 @@ async fn tc_create_team_uses_assistant_id_icon_lookup() { assert_eq!( resp.agents[0].icon.as_deref(), - Some("/api/assets/logos/ai-major/claude.svg") + Some("/api/assistants/assistant-lead/avatar") ); } #[tokio::test] async fn tc_create_team_carries_assistant_identity_into_lead_conversation_extra() { - let agent_metadata_repo: Arc = - Arc::new(StubAgentMetadataRepo::with_rows(vec![make_agent_metadata_row( - "2d23ff1c", - "claude", - "/api/assets/logos/ai-major/claude.svg", - )])); - let (svc, _task_manager, conv_repo) = - setup_with_factory_and_metadata_and_conversation_repo(success_factory(), agent_metadata_repo); + let agent_metadata_repo: Arc = Arc::new(StubAgentMetadataRepo::empty()); + let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { + row: AssistantDefinitionRow { + definition_id: "def-team-lead".into(), + assistant_key: "assistant-lead".into(), + source: "user".into(), + owner_type: "user".into(), + source_ref: None, + source_version: None, + source_hash: None, + name: "Lead Assistant".into(), + name_i18n: "{}".into(), + description: None, + description_i18n: "{}".into(), + avatar_type: "emoji".into(), + avatar_value: Some("🤖".into()), + agent_backend: "claude".into(), + rule_resource_type: "inline".into(), + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]".into(), + recommended_prompts_i18n: "{}".into(), + default_model_mode: "auto".into(), + default_model_value: None, + default_permission_mode: "auto".into(), + default_permission_value: None, + default_skills_mode: "auto".into(), + default_skill_ids: "[]".into(), + custom_skill_names: "[]".into(), + default_disabled_builtin_skill_ids: "[]".into(), + default_mcps_mode: "auto".into(), + default_mcp_ids: "[]".into(), + created_at: 0, + updated_at: 0, + deleted_at: None, + }, + }); + let (svc, _team_repo, _task_manager, conv_repo) = setup_with_factory_metadata_assistants_and_conversation_repo( + success_factory(), + agent_metadata_repo, + definition_repo, + Arc::new(EmptyAssistantOverlayRepo), + ); let resp = svc .create_team( @@ -1561,7 +1639,7 @@ async fn tc_create_team_carries_assistant_identity_into_lead_conversation_extra( role: "lead".into(), backend: Some("claude".into()), model: "claude".into(), - assistant_id: Some("2d23ff1c".into()), + assistant_id: Some("assistant-lead".into()), conversation_id: None, }], workspace: None, @@ -1577,7 +1655,7 @@ async fn tc_create_team_carries_assistant_identity_into_lead_conversation_extra( .expect("lead conversation row"); let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap(); - assert_eq!(extra["assistant_id"], serde_json::json!("2d23ff1c")); + assert_eq!(extra["assistant_id"], serde_json::json!("assistant-lead")); assert!(extra.get("preset_assistant_id").is_none()); } From 373d2fc586c055d9085ec46b1bdbe94d97d636f1 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 10:52:44 +0800 Subject: [PATCH 081/135] fix(agent): reap probe process tree to stop orphan leak ACP connection probes spawned a node/npx launcher that forks the real ACP binary (codex-acp, codebuddy --acp, ...) as a grandchild. The probe relied on drop(protocol) + kill_on_drop to clean up, but kill_on_drop only reaps the direct child; the grandchild reparents to init and leaks as an orphan. With a scheduled probe pass every 300s, orphans piled up unbounded and degraded the host. Add CliAgentProcess::force_kill_tree() which unconditionally SIGKILLs the whole process group (reusing the proven force_kill/killpg path the real session manager already uses), and drive it from an RAII guard in acp_initialize_command_spec so the tree is reaped on every exit path: success, early-return, and cancellation when the caller's 35s timeout fires and drops the future. Add regression tests covering the grandchild-after-leader-exit case and the timeout-cancellation case. --- .../src/capability/cli_process/mod.rs | 76 +++++++++++++ .../src/protocol/custom_agent_probe.rs | 102 +++++++++++++++++- 2 files changed, 175 insertions(+), 3 deletions(-) diff --git a/crates/aionui-ai-agent/src/capability/cli_process/mod.rs b/crates/aionui-ai-agent/src/capability/cli_process/mod.rs index 089ad42e8..41ec3c5b4 100644 --- a/crates/aionui-ai-agent/src/capability/cli_process/mod.rs +++ b/crates/aionui-ai-agent/src/capability/cli_process/mod.rs @@ -137,6 +137,24 @@ impl CliAgentProcess { Ok(()) } + /// Unconditionally force-kill this process and its entire process group. + /// + /// Unlike [`kill`](Self::kill), this neither closes stdin first nor waits + /// for a graceful exit, and it does **not** short-circuit when the direct + /// child has already exited. It always signals the process *group*, so a + /// descendant reparented to init after the launcher exited (e.g. an + /// npx-spawned ACP grandchild) is still reaped. + /// + /// Used by throwaway probe connections: the node/npx launcher exits on its + /// own once the ACP transport closes, but `kill_on_drop` reaps only the + /// direct child, leaving the grandchild (`codex-acp`, `codebuddy --acp`, …) + /// to leak as an orphan. + pub fn force_kill_tree(&self) { + if let Err(e) = force_kill(self.pid, self.process_group_id) { + warn!(pid = self.pid, error = %e, "force_kill_tree failed"); + } + } + /// Check whether the subprocess is still running. #[allow(dead_code)] // Complete CliProcess lifecycle API pub fn is_running(&self) -> bool { @@ -449,6 +467,64 @@ pub(super) mod tests { assert!(result.is_err()); } + #[cfg(unix)] + #[tokio::test] + async fn force_kill_tree_reaps_grandchild_after_leader_exits() { + // Reproduces the probe leak: the spawned launcher backgrounds a + // long-lived grandchild then exits 0 on its own (mirrors node/npx + // forking the real ACP binary then returning once the transport + // closes). `kill_on_drop` would only reap the direct child; the + // grandchild reparents to init and leaks. `force_kill_tree` must + // signal the whole process group and take the grandchild with it. + let marker = tempfile::NamedTempFile::new().unwrap(); + let marker_path = marker.path().to_string_lossy().into_owned(); + + let config = CommandSpec { + command: "sh".into(), + args: vec![ + "-c".into(), + "sleep 60 & child=$!; printf '%s' \"$child\" > \"$1\"; exit 0".into(), + "probe-grandchild-cleanup".into(), + marker_path.clone(), + ], + env: vec![], + cwd: None, + }; + let proc = spawn_sdk_test_process(config).await; + + // Leader exits on its own; wait for the exit monitor to observe it. + timeout(Duration::from_secs(5), proc.wait_for_exit()) + .await + .expect("leader should exit promptly"); + + let child_pid: u32 = std::fs::read_to_string(marker.path()) + .expect("grandchild pid marker should exist") + .trim() + .parse() + .expect("grandchild pid should be numeric"); + + fn is_pid_alive(pid: u32) -> bool { + let result = unsafe { libc::kill(pid as i32, 0) }; + if result == 0 { + return true; + } + !matches!(std::io::Error::last_os_error().raw_os_error(), Some(libc::ESRCH)) + } + + assert!(is_pid_alive(child_pid), "grandchild pid={child_pid} should be alive"); + + proc.force_kill_tree(); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while is_pid_alive(child_pid) && std::time::Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert!( + !is_pid_alive(child_pid), + "grandchild pid={child_pid} should be reaped by force_kill_tree", + ); + } + #[tokio::test] async fn pid_is_nonzero_for_valid_process() { let config = simple_script_config("sleep 10"); diff --git a/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs b/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs index 3e1002962..8eacfe7f3 100644 --- a/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs +++ b/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs @@ -103,11 +103,33 @@ async fn acp_initialize( acp_initialize_command_spec(spec, data_dir).await } +/// RAII guard that force-kills a probe's process tree when dropped. +/// +/// Probe connections are throwaway, and the outer callers wrap this future in +/// a `tokio::time::timeout`. On any exit — success, `?` early-return, or +/// cancellation when the timeout fires and drops the future — we must reap the +/// whole spawned process group. `kill_on_drop` only reaps the direct child, so +/// an npx-spawned ACP grandchild (`codex-acp`, `codebuddy --acp`, …) would +/// otherwise reparent to init and leak as an orphan. +struct ProbeProcessGuard<'a> { + proc: &'a CliAgentProcess, +} + +impl Drop for ProbeProcessGuard<'_> { + fn drop(&mut self) { + self.proc.force_kill_tree(); + } +} + pub(crate) async fn acp_initialize_command_spec(spec: CommandSpec, data_dir: &Path) -> Result<(), String> { let proc = CliAgentProcess::spawn_for_sdk(spec, data_dir) .await .map_err(|e| format!("spawn failed: {e}"))?; + // From here on, the process tree is reaped on every exit path (including + // a cancelled future when the caller's timeout fires). + let _guard = ProbeProcessGuard { proc: &proc }; + let (stdin, stdout) = proc .take_stdio() .await @@ -128,9 +150,8 @@ pub(crate) async fn acp_initialize_command_spec(spec: CommandSpec, data_dir: &Pa biased; res = connect => { let protocol = res.map_err(|e| format!("ACP initialize failed: {e}"))?; - // Dropping `protocol` fires the shutdown oneshot; the child - // process was spawned with `kill_on_drop(true)` via - // `aionui_runtime::Builder` so CPU stays clean. + // We only care that the handshake succeeded; drop everything and + // let `_guard` reap the process tree on the way out. drop(protocol); Ok(()) } @@ -172,6 +193,81 @@ mod tests { } } + #[cfg(unix)] + #[tokio::test] + async fn probe_reaps_grandchild_when_caller_timeout_cancels_handshake() { + // The CLI backgrounds a long-lived grandchild then hangs without ever + // speaking ACP, so the handshake never completes. The caller wraps the + // probe in a tight timeout; when it fires the probe future is dropped + // mid-flight. The drop guard must still reap the whole process group, + // including the reparented grandchild (the production orphan leak). + use aionui_common::{CommandSpec, EnvVar}; + + let marker = tempfile::NamedTempFile::new().unwrap(); + let marker_path = marker.path().to_string_lossy().into_owned(); + + let spec = CommandSpec { + command: "sh".into(), + args: vec![ + "-c".into(), + // Background a sleeper, record its pid, then block forever + // while keeping stdout open and silent so ACP initialize never + // gets a response (we must NOT echo stdin — that would look + // like a malformed reply and make `connect` fail fast). + "sleep 60 & printf '%s' \"$!\" > \"$1\"; exec sleep 60".into(), + "probe-timeout-cleanup".into(), + marker_path.clone(), + ], + env: Vec::::new(), + cwd: None, + }; + + // Warm the lazily-loaded shell-env cache so the spawn below is not + // racing a cold login-shell capture against our timeout. + let _ = aionui_runtime::agent_process_env().await; + + let tmp = std::env::temp_dir(); + let result = tokio::time::timeout(Duration::from_secs(2), acp_initialize_command_spec(spec, &tmp)).await; + assert!( + result.is_err(), + "handshake should not complete; outer timeout must fire" + ); + + let child_pid: u32 = { + // The marker is written very early; poll briefly in case of races. + let deadline = std::time::Instant::now() + Duration::from_secs(2); + loop { + if let Ok(contents) = std::fs::read_to_string(marker.path()) + && let Ok(pid) = contents.trim().parse::() + { + break pid; + } + assert!( + std::time::Instant::now() < deadline, + "grandchild pid marker never appeared" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + }; + + fn is_pid_alive(pid: u32) -> bool { + let rc = unsafe { libc::kill(pid as i32, 0) }; + if rc == 0 { + return true; + } + !matches!(std::io::Error::last_os_error().raw_os_error(), Some(libc::ESRCH)) + } + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while is_pid_alive(child_pid) && std::time::Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert!( + !is_pid_alive(child_pid), + "grandchild pid={child_pid} should be reaped after the probe future is cancelled", + ); + } + #[tokio::test] async fn probe_returns_fail_acp_when_command_is_noop() { // `true` exits 0 immediately — Step 1 passes (on PATH), but the From 171ca04ae708ebf8578b660d96c5b5d9205890c7 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 11:08:10 +0800 Subject: [PATCH 082/135] fix(agent): key logo catalog by agent_type when backend is null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aion CLI (aionrs) seeds with a NULL backend column, so it was dropped from the /api/agents/logos catalog and aionrs conversations resolved no icon. Key each entry by its vendor backend when present, otherwise by agent_type — the same runtime key the frontend derives from conversation.type for backends without a vendor label. --- crates/aionui-ai-agent/src/services/agent.rs | 16 ++++++++++++---- crates/aionui-app/tests/agent_integration_e2e.rs | 4 ++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/aionui-ai-agent/src/services/agent.rs b/crates/aionui-ai-agent/src/services/agent.rs index d13c90b56..a1fca8add 100644 --- a/crates/aionui-ai-agent/src/services/agent.rs +++ b/crates/aionui-ai-agent/src/services/agent.rs @@ -106,14 +106,22 @@ impl AgentService { let mut seen = std::collections::HashSet::new(); let mut entries = Vec::new(); for agent in self.registry.list_all_including_hidden().await { - let (Some(backend), Some(logo)) = (agent.backend, agent.icon) else { + let Some(logo) = agent.icon.filter(|value| !value.is_empty()) else { continue; }; - if backend.is_empty() || logo.is_empty() { + // Frontend rows resolve a logo from the conversation's runtime key, + // which is the vendor `backend` for ACP agents but the `agent_type` + // for backends without a vendor label (e.g. aionrs, where `backend` + // is NULL). Key on `backend` when present, otherwise the agent_type. + let key = agent + .backend + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| agent.agent_type.serde_name().to_owned()); + if key.is_empty() { continue; } - if seen.insert(backend.clone()) { - entries.push(AgentLogoEntry { backend, logo }); + if seen.insert(key.clone()) { + entries.push(AgentLogoEntry { backend: key, logo }); } } Ok(entries) diff --git a/crates/aionui-app/tests/agent_integration_e2e.rs b/crates/aionui-app/tests/agent_integration_e2e.rs index cf70aff89..826aaec9d 100644 --- a/crates/aionui-app/tests/agent_integration_e2e.rs +++ b/crates/aionui-app/tests/agent_integration_e2e.rs @@ -345,6 +345,10 @@ async fn agent_logos_endpoint_returns_backend_to_logo_catalog() { Some("/api/assets/logos/tools/coding/codex.svg") ); + // Aion CLI has no vendor `backend` (NULL); it must still be keyed by its + // agent_type ("aionrs") so aionrs conversations resolve a logo. + assert_eq!(logo_for("aionrs").as_deref(), Some("/api/assets/logos/brand/aion.svg")); + // Every entry carries a non-empty backend + logo, and backends are unique. let mut seen = std::collections::HashSet::new(); for entry in entries { From 8c617843afd8e7e14b70f78bbebe34b9ee9ac866 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 11:24:59 +0800 Subject: [PATCH 083/135] refactor(team): reject legacy stdio command aliases --- .../aionui-app/src/commands/cmd_team_guide.rs | 30 +++++++++++++++++-- .../aionui-app/src/commands/cmd_team_stdio.rs | 30 +++++++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/aionui-app/src/commands/cmd_team_guide.rs b/crates/aionui-app/src/commands/cmd_team_guide.rs index 3f3121750..ccbb00d73 100644 --- a/crates/aionui-app/src/commands/cmd_team_guide.rs +++ b/crates/aionui-app/src/commands/cmd_team_guide.rs @@ -150,11 +150,12 @@ struct SendMessageParams { } #[derive(Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] struct SpawnAgentParams { /// Name for the new teammate agent. name: String, /// Assistant identifier from the available assistants catalog. - #[serde(default, alias = "custom_agent_id")] + #[serde(default)] assistant_id: Option, /// Model override for the new agent. #[serde(default)] @@ -219,9 +220,9 @@ struct TeamListModelsParams { } #[derive(Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] struct DescribeAssistantParams { /// Assistant identifier to look up. - #[serde(alias = "custom_agent_id")] assistant_id: String, /// Locale for the description (e.g. "en", "zh"). Default when omitted. #[serde(default)] @@ -437,6 +438,31 @@ mod tests { assert_eq!(env.user_id, "user-1"); } + #[test] + fn spawn_agent_params_reject_legacy_custom_agent_id_alias() { + let parsed = serde_json::from_value::(json!({ + "name": "helper", + "custom_agent_id": "assistant-123", + })); + assert!(parsed.is_err(), "legacy custom_agent_id alias should be rejected"); + let err = parsed.err().unwrap(); + + assert!(err.to_string().contains("unknown field")); + assert!(err.to_string().contains("custom_agent_id")); + } + + #[test] + fn describe_assistant_params_reject_legacy_custom_agent_id_alias() { + let parsed = serde_json::from_value::(json!({ + "custom_agent_id": "assistant-123", + })); + assert!(parsed.is_err(), "legacy custom_agent_id alias should be rejected"); + let err = parsed.err().unwrap(); + + assert!(err.to_string().contains("unknown field")); + assert!(err.to_string().contains("custom_agent_id")); + } + fn guide_server_for_port(port: u16) -> GuideServer { GuideServer { port, diff --git a/crates/aionui-app/src/commands/cmd_team_stdio.rs b/crates/aionui-app/src/commands/cmd_team_stdio.rs index 6336af73c..48f74b994 100644 --- a/crates/aionui-app/src/commands/cmd_team_stdio.rs +++ b/crates/aionui-app/src/commands/cmd_team_stdio.rs @@ -126,6 +126,7 @@ struct SendMessageParams { } #[derive(Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] struct SpawnAgentParams { /// Agent display name. name: String, @@ -133,7 +134,7 @@ struct SpawnAgentParams { #[serde(default)] model: Option, /// Assistant identifier from the available assistants catalog. - #[serde(default, alias = "custom_agent_id")] + #[serde(default)] assistant_id: Option, /// Agent role (default: "teammate"). #[serde(default)] @@ -198,9 +199,9 @@ struct ListModelsParams { } #[derive(Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] struct DescribeAssistantParams { /// The assistant ID from the "Available Assistants" catalog. - #[serde(alias = "custom_agent_id")] assistant_id: String, /// Locale for the description (e.g. "en", "zh"). Default when omitted. #[serde(default)] @@ -572,6 +573,31 @@ mod tests { assert_eq!(env.slot_id, "slot-a"); } + #[test] + fn spawn_agent_params_reject_legacy_custom_agent_id_alias() { + let parsed = serde_json::from_value::(json!({ + "name": "helper", + "custom_agent_id": "assistant-123", + })); + assert!(parsed.is_err(), "legacy custom_agent_id alias should be rejected"); + let err = parsed.err().unwrap(); + + assert!(err.to_string().contains("unknown field")); + assert!(err.to_string().contains("custom_agent_id")); + } + + #[test] + fn describe_assistant_params_reject_legacy_custom_agent_id_alias() { + let parsed = serde_json::from_value::(json!({ + "custom_agent_id": "assistant-123", + })); + assert!(parsed.is_err(), "legacy custom_agent_id alias should be rejected"); + let err = parsed.err().unwrap(); + + assert!(err.to_string().contains("unknown field")); + assert!(err.to_string().contains("custom_agent_id")); + } + #[tokio::test] async fn forward_to_tcp_reports_read_failure_after_accept_close() { let listener = TcpListener::bind((CONNECT_HOST, 0)).await.unwrap(); From aeb842b15d1fc90c0b4585ec3ad03905791c38a0 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 11:34:38 +0800 Subject: [PATCH 084/135] refactor(cron): require assistant backend resolution on update --- crates/aionui-cron/src/service.rs | 1 - .../aionui-cron/tests/service_integration.rs | 70 ++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index 170c76902..cdd538072 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -942,7 +942,6 @@ impl CronService { } else if let Some(assistant_id) = config.assistant_id.as_deref() { self.resolve_assistant_backend(Some(assistant_id)) .await? - .or_else(|| config.backend.clone().filter(|value| !value.trim().is_empty())) .ok_or_else(|| { CronError::InvalidAgentConfig(format!( "assistant '{assistant_id}' could not resolve a runtime backend" diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index a932f1c03..8745f3133 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -1051,6 +1051,31 @@ async fn create_job_allows_assistant_backed_acp_jobs_without_backend_hint() { assert_eq!(config.backend, "claude"); } +#[tokio::test] +async fn create_job_rejects_backend_fallback_when_assistant_id_cannot_resolve() { + let (svc, _, _) = setup().await; + + let mut req = make_create_req("Assistant Missing", every_60s()); + req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { + backend: Some("claude".into()), + name: "Helper".into(), + cli_path: None, + assistant_id: Some("missing-assistant".into()), + mode: Some("default".into()), + model_id: Some("claude-sonnet-4".into()), + config_options: None, + workspace: None, + }); + + let err = svc + .add_job(req) + .await + .expect_err("missing assistant must not fall back to backend"); + + assert!(matches!(err, aionui_cron::error::CronError::InvalidAgentConfig(_))); + assert!(err.to_string().contains("missing-assistant"), "unexpected error: {err}"); +} + // ── CJ-2: Create three schedule types ────────────────────────────── #[tokio::test] @@ -1188,7 +1213,8 @@ async fn cj8_update_job() { #[tokio::test] async fn update_job_strips_legacy_agent_ids_when_assistant_id_present() { - let (svc, _, _) = setup().await; + let (svc, _, _, _, definition_repo, _) = setup_with_assistant_repos().await; + seed_assistant_definition(&definition_repo, "asstdef_update_assistant_1", "assistant-1", "claude").await; let created = svc .add_job(make_create_req("Assistant Only Update", every_60s())) .await @@ -1224,6 +1250,48 @@ async fn update_job_strips_legacy_agent_ids_when_assistant_id_present() { assert!(config.is_preset.is_none()); } +#[tokio::test] +async fn update_job_rejects_backend_fallback_when_assistant_id_cannot_resolve() { + let (svc, _, _) = setup().await; + let created = svc + .add_job(make_create_req("Assistant Missing Update", every_60s())) + .await + .unwrap(); + + let req = UpdateCronJobRequest { + name: None, + description: None, + enabled: None, + schedule: None, + message: None, + execution_mode: None, + agent_config: Some(aionui_api_types::CronAgentConfigWriteDto { + backend: Some("claude".into()), + name: "Helper".into(), + cli_path: None, + assistant_id: Some("missing-assistant".into()), + mode: Some("default".into()), + model_id: Some("claude-sonnet-4".into()), + config_options: None, + workspace: None, + }), + conversation_title: None, + max_retries: None, + }; + + let err = svc + .update_job(&created.id, req) + .await + .expect_err("missing assistant must not fall back to backend"); + + assert!(matches!(err, aionui_cron::error::CronError::InvalidAgentConfig(_))); + assert!( + err.to_string() + .contains("assistant 'missing-assistant' could not resolve a runtime backend"), + "unexpected error: {err}" + ); +} + // ── CJ-9: Update schedule type ──────────────────────────────────── #[tokio::test] From 0ed5e364f5e61fde0f2db8090623bfb6f0032195 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 11:54:16 +0800 Subject: [PATCH 085/135] refactor(team): prefer assistant-first response fields --- crates/aionui-api-types/src/team.rs | 47 ++++++++++--------- crates/aionui-team/src/events.rs | 8 ++-- .../src/service/response_builder.rs | 4 +- .../aionui-team/src/service/spawn_support.rs | 2 +- crates/aionui-team/src/types.rs | 10 ++-- 5 files changed, 37 insertions(+), 34 deletions(-) diff --git a/crates/aionui-api-types/src/team.rs b/crates/aionui-api-types/src/team.rs index 4179a402e..8c8f8a969 100644 --- a/crates/aionui-api-types/src/team.rs +++ b/crates/aionui-api-types/src/team.rs @@ -402,9 +402,10 @@ pub struct TeamResponse { pub name: String, #[serde(default)] pub workspace: String, - pub agents: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub lead_agent_id: Option, + #[serde(alias = "agents")] + pub assistants: Vec, + #[serde(skip_serializing_if = "Option::is_none", alias = "lead_agent_id")] + pub leader_assistant_id: Option, pub created_at: TimestampMs, pub updated_at: TimestampMs, } @@ -433,7 +434,8 @@ pub struct TeamAgentStatusPayload { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct TeamAgentSpawnedPayload { pub team_id: String, - pub agent: TeamAgentResponse, + #[serde(alias = "agent")] + pub assistant: TeamAgentResponse, } /// Payload for `team.agentRemoved` WebSocket event. @@ -873,7 +875,7 @@ mod tests { id: "team-1".into(), name: "Alpha".into(), workspace: "/workspace/team-1".into(), - agents: vec![TeamAgentResponse { + assistants: vec![TeamAgentResponse { slot_id: "slot-1".into(), assistant_name: "Lead".into(), name: "Lead".into(), @@ -887,7 +889,7 @@ mod tests { status: None, pending_confirmations: 0, }], - lead_agent_id: Some("slot-1".into()), + leader_assistant_id: Some("slot-1".into()), created_at: 1700000000000, updated_at: 1700001000000, }; @@ -895,11 +897,11 @@ mod tests { assert_eq!(json["id"], "team-1"); assert_eq!(json["name"], "Alpha"); assert_eq!(json["workspace"], "/workspace/team-1"); - assert_eq!(json["lead_agent_id"], "slot-1"); + assert_eq!(json["leader_assistant_id"], "slot-1"); assert_eq!(json["created_at"], 1700000000000_i64); assert_eq!(json["updated_at"], 1700001000000_i64); - assert_eq!(json["agents"].as_array().unwrap().len(), 1); - assert_eq!(json["agents"][0]["slot_id"], "slot-1"); + assert_eq!(json["assistants"].as_array().unwrap().len(), 1); + assert_eq!(json["assistants"][0]["slot_id"], "slot-1"); } #[test] @@ -908,14 +910,14 @@ mod tests { id: "team-2".into(), name: "Beta".into(), workspace: String::new(), - agents: vec![], - lead_agent_id: None, + assistants: vec![], + leader_assistant_id: None, created_at: 1700000000000, updated_at: 1700000000000, }; let json = serde_json::to_value(&team).unwrap(); - assert!(json.get("lead_agent_id").is_none()); - assert!(json["agents"].as_array().unwrap().is_empty()); + assert!(json.get("leader_assistant_id").is_none()); + assert!(json["assistants"].as_array().unwrap().is_empty()); } // -- E. WebSocket event payloads ------------------------------------------ @@ -937,7 +939,7 @@ mod tests { fn serialize_team_agent_spawned_payload() { let payload = TeamAgentSpawnedPayload { team_id: "team-1".into(), - agent: TeamAgentResponse { + assistant: TeamAgentResponse { slot_id: "slot-3".into(), assistant_name: "Dynamic Worker".into(), name: "Dynamic Worker".into(), @@ -954,10 +956,10 @@ mod tests { }; let json = serde_json::to_value(&payload).unwrap(); assert_eq!(json["team_id"], "team-1"); - assert_eq!(json["agent"]["slot_id"], "slot-3"); - assert_eq!(json["agent"]["name"], "Dynamic Worker"); - assert_eq!(json["agent"]["role"], "teammate"); - assert_eq!(json["agent"]["status"], "idle"); + assert_eq!(json["assistant"]["slot_id"], "slot-3"); + assert_eq!(json["assistant"]["name"], "Dynamic Worker"); + assert_eq!(json["assistant"]["role"], "teammate"); + assert_eq!(json["assistant"]["status"], "idle"); } #[test] @@ -1013,7 +1015,7 @@ mod tests { id: "team-1".into(), name: "Alpha".into(), workspace: "/workspace/team-1".into(), - agents: vec![ + assistants: vec![ TeamAgentResponse { slot_id: "s1".into(), assistant_name: "Lead".into(), @@ -1043,7 +1045,7 @@ mod tests { pending_confirmations: 3, }, ], - lead_agent_id: Some("s1".into()), + leader_assistant_id: Some("s1".into()), created_at: 1000, updated_at: 2000, }; @@ -1068,7 +1070,7 @@ mod tests { fn team_agent_spawned_payload_roundtrip() { let payload = TeamAgentSpawnedPayload { team_id: "t1".into(), - agent: TeamAgentResponse { + assistant: TeamAgentResponse { slot_id: "s3".into(), assistant_name: "New".into(), name: "New".into(), @@ -1145,7 +1147,8 @@ mod tests { }); let team: TeamResponse = serde_json::from_value(raw).unwrap(); assert_eq!(team.id, "team-1"); - assert_eq!(team.lead_agent_id.as_deref(), Some("s1")); + assert!(team.assistants.is_empty()); + assert_eq!(team.leader_assistant_id.as_deref(), Some("s1")); assert_eq!(team.created_at, 1000); } diff --git a/crates/aionui-team/src/events.rs b/crates/aionui-team/src/events.rs index 57794538d..1ba467e17 100644 --- a/crates/aionui-team/src/events.rs +++ b/crates/aionui-team/src/events.rs @@ -61,7 +61,7 @@ impl TeamEventEmitter { pub fn broadcast_agent_spawned(&self, agent: &TeamAgent) { let payload = TeamAgentSpawnedPayload { team_id: self.team_id.clone(), - agent: agent.to_response(), + assistant: agent.to_response(), }; let event = WebSocketMessage::new( TEAM_AGENT_SPAWNED_EVENT, @@ -210,9 +210,9 @@ mod tests { let payload: TeamAgentSpawnedPayload = serde_json::from_value(events[0].data.clone()).unwrap(); assert_eq!(payload.team_id, "team-1"); - assert_eq!(payload.agent.slot_id, "slot-2"); - assert_eq!(payload.agent.name, "Worker"); - assert_eq!(payload.agent.role, "teammate"); + assert_eq!(payload.assistant.slot_id, "slot-2"); + assert_eq!(payload.assistant.name, "Worker"); + assert_eq!(payload.assistant.role, "teammate"); } #[test] diff --git a/crates/aionui-team/src/service/response_builder.rs b/crates/aionui-team/src/service/response_builder.rs index a789d9f82..334d50903 100644 --- a/crates/aionui-team/src/service/response_builder.rs +++ b/crates/aionui-team/src/service/response_builder.rs @@ -11,8 +11,8 @@ impl TeamSessionService { id: team.id.clone(), name: team.name.clone(), workspace: team.workspace.clone(), - agents, - lead_agent_id: team.lead_agent_id.clone(), + assistants: agents, + leader_assistant_id: team.lead_agent_id.clone(), created_at: team.created_at, updated_at: team.updated_at, }) diff --git a/crates/aionui-team/src/service/spawn_support.rs b/crates/aionui-team/src/service/spawn_support.rs index 1dd33db54..a1fb881ef 100644 --- a/crates/aionui-team/src/service/spawn_support.rs +++ b/crates/aionui-team/src/service/spawn_support.rs @@ -510,7 +510,7 @@ mod tests { .create_team("user1", single_agent_team_request("Spawn Legacy")) .await .unwrap(); - let leader_workspace = conv_repo.get_extra(&created.agents[0].conversation_id).unwrap()["workspace"] + let leader_workspace = conv_repo.get_extra(&created.assistants[0].conversation_id).unwrap()["workspace"] .as_str() .unwrap() .to_owned(); diff --git a/crates/aionui-team/src/types.rs b/crates/aionui-team/src/types.rs index eba6c8492..7c7dca5f0 100644 --- a/crates/aionui-team/src/types.rs +++ b/crates/aionui-team/src/types.rs @@ -291,8 +291,8 @@ impl Team { id: self.id.clone(), name: self.name.clone(), workspace: self.workspace.clone(), - agents: self.agents.iter().map(|a| a.to_response()).collect(), - lead_agent_id: self.lead_agent_id.clone(), + assistants: self.agents.iter().map(|a| a.to_response()).collect(), + leader_assistant_id: self.lead_agent_id.clone(), created_at: self.created_at, updated_at: self.updated_at, } @@ -676,9 +676,9 @@ mod tests { let resp = team.to_response(); assert_eq!(resp.id, "t1"); assert_eq!(resp.name, "Alpha"); - assert_eq!(resp.agents.len(), 1); - assert_eq!(resp.agents[0].slot_id, "s1"); - assert_eq!(resp.lead_agent_id.as_deref(), Some("s1")); + assert_eq!(resp.assistants.len(), 1); + assert_eq!(resp.assistants[0].slot_id, "s1"); + assert_eq!(resp.leader_assistant_id.as_deref(), Some("s1")); assert_eq!(resp.created_at, 1000); assert_eq!(resp.updated_at, 2000); } From 49ef39fce2bf87fb7c75befd7bf771ac9ba88407 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 12:09:38 +0800 Subject: [PATCH 086/135] fix(team): append gemini to guide backends response --- crates/aionui-team/src/guide/server.rs | 92 ++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 5 deletions(-) diff --git a/crates/aionui-team/src/guide/server.rs b/crates/aionui-team/src/guide/server.rs index bee07be67..db8c54c9e 100644 --- a/crates/aionui-team/src/guide/server.rs +++ b/crates/aionui-team/src/guide/server.rs @@ -157,13 +157,13 @@ async fn handle_tool_request( } }; // Guide surfaces Gemini even if not in spawn whitelist - if let Some(types) = base.get_mut("agent_types").and_then(serde_json::Value::as_array_mut) { - let has_gemini = types + if let Some(backends) = base.get_mut("backends").and_then(serde_json::Value::as_array_mut) { + let has_gemini = backends .iter() - .any(|e| e.get("type").and_then(serde_json::Value::as_str) == Some("gemini")); + .any(|entry| entry.get("backend").and_then(serde_json::Value::as_str) == Some("gemini")); if !has_gemini { - types.push(serde_json::json!({ - "type": "gemini", + backends.push(serde_json::json!({ + "backend": "gemini", "models": ["gemini-2.5-pro", "gemini-2.5-flash"] })); } @@ -605,6 +605,88 @@ mod tests { assert!(body.get("result").is_some()); } + #[tokio::test] + async fn service_backed_list_models_appends_gemini_to_backends() { + let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { + row: AssistantDefinitionRow { + definition_id: "def-guide-models".into(), + assistant_key: "assistant-models".into(), + source: "user".into(), + owner_type: "user".into(), + source_ref: None, + source_version: None, + source_hash: None, + name: "Models Assistant".into(), + name_i18n: "{}".into(), + description: None, + description_i18n: "{}".into(), + avatar_type: "emoji".into(), + avatar_value: Some("🤖".into()), + agent_backend: "claude".into(), + rule_resource_type: "inline".into(), + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]".into(), + recommended_prompts_i18n: "{}".into(), + default_model_mode: "auto".into(), + default_model_value: None, + default_permission_mode: "auto".into(), + default_permission_value: None, + default_skills_mode: "auto".into(), + default_skill_ids: "[]".into(), + custom_skill_names: "[]".into(), + default_disabled_builtin_skill_ids: "[]".into(), + default_mcps_mode: "auto".into(), + default_mcp_ids: "[]".into(), + created_at: 0, + updated_at: 0, + deleted_at: None, + }, + }); + let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { + row: AssistantOverlayRow { + definition_id: "def-guide-models".into(), + enabled: true, + sort_order: 0, + agent_backend_override: None, + last_used_at: None, + created_at: 0, + updated_at: 0, + }, + }); + let (svc, _team_repo, _task_manager, _conv_repo) = + setup_with_assistants_team_repo_and_conversation_repo(definition_repo, overlay_repo); + + let server = GuideMcpServer::start().await.expect("start guide server"); + server.set_service(Arc::downgrade(&svc)).await; + + let client = reqwest::Client::new(); + let resp = client + .post(format!("http://127.0.0.1:{}/tool", server.http_port())) + .header("Authorization", format!("Bearer {}", server.auth_token())) + .json(&serde_json::json!({ + "tool": "aion_list_models", + "args": {} + })) + .send() + .await + .expect("call guide list models"); + assert_eq!(resp.status(), StatusCode::OK); + + let body: serde_json::Value = resp.json().await.expect("guide list models response"); + let result = serde_json::from_str::(body["result"].as_str().expect("result string payload")) + .expect("parse result payload"); + let backends = result["backends"].as_array().expect("backends array"); + assert!( + backends.iter().any(|entry| entry["backend"].as_str() == Some("gemini")), + "service-backed list_models should still advertise gemini", + ); + assert!( + result.get("agent_types").is_none(), + "guide payload should no longer expose legacy agent_types", + ); + } + #[tokio::test] async fn create_team_uses_assistant_identity_from_caller_conversation() { let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { From cc2d3f8dc5de0940ee22209f372d13f055ec2b18 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 12:22:57 +0800 Subject: [PATCH 087/135] refactor(cron): prefer assistant ids in task extras --- crates/aionui-cron/src/executor.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/aionui-cron/src/executor.rs b/crates/aionui-cron/src/executor.rs index 94408d538..506dfa601 100644 --- a/crates/aionui-cron/src/executor.rs +++ b/crates/aionui-cron/src/executor.rs @@ -1055,13 +1055,18 @@ async fn build_task_extra(registry: &AgentRegistry, job: &CronJob, skills: &[Str if !config.name.is_empty() { extra.insert("agent_name".to_owned(), serde_json::Value::String(config.name.clone())); } + let has_assistant_id = config + .assistant_id + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()); if let Some(assistant_id) = &config.assistant_id { extra.insert( "assistant_id".to_owned(), serde_json::Value::String(assistant_id.clone()), ); } - if let Some(custom_agent_id) = &config.custom_agent_id { + if !has_assistant_id && let Some(custom_agent_id) = &config.custom_agent_id { extra.insert( "custom_agent_id".to_owned(), serde_json::Value::String(custom_agent_id.clone()), @@ -1658,6 +1663,22 @@ mod tests { assert_eq!(extra["skills"], serde_json::json!(["cron-cron_test1"])); } + #[tokio::test] + async fn build_task_extra_omits_legacy_assistant_identity_when_assistant_id_is_present() { + let registry = hydrated_registry().await; + let mut job = sample_job(); + let config = job.agent_config.as_mut().expect("sample job should carry config"); + config.assistant_id = Some("assistant-sample".into()); + config.custom_agent_id = Some("legacy-custom".into()); + config.is_preset = Some(true); + + let extra = build_task_extra(®istry, &job, &[]).await; + + assert_eq!(extra["assistant_id"], "assistant-sample"); + assert!(extra.get("custom_agent_id").is_none()); + assert!(extra.get("preset_assistant_id").is_none()); + } + #[tokio::test] async fn build_task_extra_without_config() { let registry = hydrated_registry().await; From e72d95559e1707616fc1d0055b4a8b857e105f69 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 12:29:55 +0800 Subject: [PATCH 088/135] refactor(team): reject legacy guide assistant aliases --- crates/aionui-team/src/guide/server.rs | 32 ++++++- .../tests/prompts_events_integration.rs | 6 +- .../tests/session_service_integration.rs | 84 +++++++++---------- 3 files changed, 76 insertions(+), 46 deletions(-) diff --git a/crates/aionui-team/src/guide/server.rs b/crates/aionui-team/src/guide/server.rs index db8c54c9e..bf41e26ca 100644 --- a/crates/aionui-team/src/guide/server.rs +++ b/crates/aionui-team/src/guide/server.rs @@ -305,7 +305,6 @@ async fn exec_create_team( fn extract_assistant_id(value: &serde_json::Value) -> Option { value .get("assistant_id") - .or_else(|| value.get("custom_agent_id")) .and_then(serde_json::Value::as_str) .or_else(|| { value @@ -318,12 +317,24 @@ fn extract_assistant_id(value: &serde_json::Value) -> Option { .map(str::to_owned) } +fn validate_requested_assistant_identity_payload( + request_body: &serde_json::Value, + args: &serde_json::Value, +) -> Result<(), String> { + if request_body.get("custom_agent_id").is_some() || args.get("custom_agent_id").is_some() { + return Err("custom_agent_id is no longer accepted; use assistant_id".to_owned()); + } + Ok(()) +} + async fn resolve_requested_assistant_id( service: &Arc, request_body: &serde_json::Value, args: &serde_json::Value, caller_conversation_id: Option<&str>, ) -> Result { + validate_requested_assistant_identity_payload(request_body, args)?; + if let Some(assistant_id) = extract_assistant_id(request_body).or_else(|| extract_assistant_id(args)) { return Ok(assistant_id); } @@ -563,6 +574,25 @@ mod tests { } } + #[test] + fn extract_assistant_id_ignores_legacy_custom_agent_id() { + let payload = serde_json::json!({ + "custom_agent_id": "legacy-assistant", + }); + assert!(extract_assistant_id(&payload).is_none()); + } + + #[test] + fn validate_requested_assistant_identity_payload_rejects_legacy_custom_agent_id() { + let err = validate_requested_assistant_identity_payload( + &serde_json::json!({ "custom_agent_id": "legacy-assistant" }), + &serde_json::json!({}), + ) + .expect_err("legacy custom_agent_id should be rejected"); + + assert!(err.contains("custom_agent_id")); + } + #[tokio::test] async fn stop_is_idempotent() { let mut server = GuideMcpServer::start().await.unwrap(); diff --git a/crates/aionui-team/tests/prompts_events_integration.rs b/crates/aionui-team/tests/prompts_events_integration.rs index 74f0d376a..19629192b 100644 --- a/crates/aionui-team/tests/prompts_events_integration.rs +++ b/crates/aionui-team/tests/prompts_events_integration.rs @@ -478,8 +478,8 @@ async fn we2_agent_spawned_event() { let payload: TeamAgentSpawnedPayload = serde_json::from_value(spawned[0].data.clone()).unwrap(); assert_eq!(payload.team_id, "t1"); - assert_eq!(payload.agent.slot_id, "w2"); - assert_eq!(payload.agent.name, "NewWorker"); + assert_eq!(payload.assistant.slot_id, "w2"); + assert_eq!(payload.assistant.name, "NewWorker"); } // -- WE-3: Agent removed event ----------------------------------------------- @@ -559,7 +559,7 @@ fn event_emitter_uses_typed_payloads() { assert_eq!(p1.status, "thinking"); let p2: TeamAgentSpawnedPayload = serde_json::from_value(events[1].data.clone()).unwrap(); - assert_eq!(p2.agent.slot_id, "s1"); + assert_eq!(p2.assistant.slot_id, "s1"); let p3: TeamAgentRemovedPayload = serde_json::from_value(events[2].data.clone()).unwrap(); assert_eq!(p3.slot_id, "s1"); diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index 948dc6dc1..7dd845fab 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -1432,11 +1432,11 @@ async fn tc1_create_team_with_multiple_agents() { .unwrap(); assert_eq!(resp.name, "Alpha"); - assert_eq!(resp.agents.len(), 2); - assert_eq!(resp.agents[0].role, "lead"); - assert_eq!(resp.agents[1].role, "teammate"); - assert!(resp.lead_agent_id.is_some()); - assert_eq!(resp.lead_agent_id, Some(resp.agents[0].slot_id.clone())); + assert_eq!(resp.assistants.len(), 2); + assert_eq!(resp.assistants[0].role, "lead"); + assert_eq!(resp.assistants[1].role, "teammate"); + assert!(resp.leader_assistant_id.is_some()); + assert_eq!(resp.leader_assistant_id, Some(resp.assistants[0].slot_id.clone())); } #[tokio::test] @@ -1463,7 +1463,7 @@ async fn create_team_with_workspace_writes_same_workspace_to_team_and_initial_ag let got = svc.get_team("user1", &created.id).await.unwrap(); assert_eq!(got.workspace, workspace); - for agent in &got.agents { + for agent in &got.assistants { let extra = conv_repo.get_extra(&agent.conversation_id).unwrap(); assert_eq!( extra.get("workspace").and_then(serde_json::Value::as_str), @@ -1498,7 +1498,7 @@ async fn create_team_without_workspace_uses_leader_auto_workspace_for_all_initia got.workspace ); - for agent in &got.agents { + for agent in &got.assistants { let extra = conv_repo.get_extra(&agent.conversation_id).unwrap(); assert_eq!( extra.get("workspace").and_then(serde_json::Value::as_str), @@ -1578,7 +1578,7 @@ async fn tc_create_team_prefers_assistant_avatar_over_backend_logo() { .unwrap(); assert_eq!( - resp.agents[0].icon.as_deref(), + resp.assistants[0].icon.as_deref(), Some("/api/assistants/assistant-lead/avatar") ); } @@ -1649,7 +1649,7 @@ async fn tc_create_team_carries_assistant_identity_into_lead_conversation_extra( .unwrap(); let row = conv_repo - .get(&resp.agents[0].conversation_id) + .get(&resp.assistants[0].conversation_id) .await .unwrap() .expect("lead conversation row"); @@ -1735,9 +1735,9 @@ async fn tc_create_team_derives_backend_from_assistant_when_backend_missing() { .await .unwrap(); - assert_eq!(created.agents[0].backend, "codex"); + assert_eq!(created.assistants[0].backend, "codex"); let extra = conv_repo - .get_extra(&created.agents[0].conversation_id) + .get_extra(&created.assistants[0].conversation_id) .expect("lead conversation extra"); assert_eq!(extra.get("backend").and_then(serde_json::Value::as_str), Some("codex")); assert_eq!( @@ -1822,9 +1822,9 @@ async fn tc_create_team_ignores_requested_backend_when_assistant_id_present() { .await .unwrap(); - assert_eq!(created.agents[0].backend, "codex"); + assert_eq!(created.assistants[0].backend, "codex"); let extra = conv_repo - .get_extra(&created.agents[0].conversation_id) + .get_extra(&created.assistants[0].conversation_id) .expect("lead conversation extra"); assert_eq!(extra.get("backend").and_then(serde_json::Value::as_str), Some("codex")); } @@ -2085,8 +2085,8 @@ async fn tc2_create_single_agent_team() { .await .unwrap(); - assert_eq!(resp.agents.len(), 1); - assert_eq!(resp.agents[0].role, "lead"); + assert_eq!(resp.assistants.len(), 1); + assert_eq!(resp.assistants[0].role, "lead"); } #[tokio::test] @@ -2121,8 +2121,8 @@ async fn tc4_first_agent_is_lead() { .await .unwrap(); - assert_eq!(resp.agents[0].role, "lead"); - assert_eq!(resp.lead_agent_id, Some(resp.agents[0].slot_id.clone())); + assert_eq!(resp.assistants[0].role, "lead"); + assert_eq!(resp.leader_assistant_id, Some(resp.assistants[0].slot_id.clone())); } #[tokio::test] @@ -2156,10 +2156,10 @@ async fn tc3_each_agent_has_conversation_id() { .await .unwrap(); - for agent in &resp.agents { + for agent in &resp.assistants { assert!(!agent.conversation_id.is_empty()); } - assert_ne!(resp.agents[0].conversation_id, resp.agents[1].conversation_id); + assert_ne!(resp.assistants[0].conversation_id, resp.assistants[1].conversation_id); } // -- List teams --------------------------------------------------------------- @@ -2250,7 +2250,7 @@ async fn tl_list_teams_includes_pending_confirmation_counts_without_rebuilding_t ) .await .unwrap(); - let conversation_id = created.agents[0].conversation_id.clone(); + let conversation_id = created.assistants[0].conversation_id.clone(); task_manager .get_or_build_task( &conversation_id, @@ -2265,7 +2265,7 @@ async fn tl_list_teams_includes_pending_confirmation_counts_without_rebuilding_t assert_eq!(list.len(), 1); assert_eq!(list[0].id, created.id); - assert_eq!(list[0].agents[0].pending_confirmations, 2); + assert_eq!(list[0].assistants[0].pending_confirmations, 2); assert_eq!(after.build, before.build); } @@ -2289,7 +2289,7 @@ async fn tg1_get_existing_team() { let got = svc.get_team("user1", &created.id).await.unwrap(); assert_eq!(got.id, created.id); assert_eq!(got.name, "Alpha"); - assert_eq!(got.agents.len(), 2); + assert_eq!(got.assistants.len(), 2); } #[tokio::test] @@ -2443,7 +2443,7 @@ async fn aa1_add_agent_to_team() { assert!(!agent.conversation_id.is_empty()); let got = svc.get_team("user1", &created.id).await.unwrap(); - assert_eq!(got.agents.len(), 2); + assert_eq!(got.assistants.len(), 2); } #[tokio::test] @@ -2518,7 +2518,7 @@ async fn add_agent_backfills_empty_team_workspace_from_leader_workspace() { ) .await .unwrap(); - let leader_workspace = conv_repo.get_extra(&created.agents[0].conversation_id).unwrap()["workspace"] + let leader_workspace = conv_repo.get_extra(&created.assistants[0].conversation_id).unwrap()["workspace"] .as_str() .unwrap() .to_owned(); @@ -2576,7 +2576,7 @@ async fn add_agent_uses_team_temp_workspace_when_team_and_leader_workspaces_are_ force_team_workspace(&team_repo, &created.id, "").await; conv_repo .patch_extra( - &created.agents[0].conversation_id, + &created.assistants[0].conversation_id, serde_json::json!({ "workspace": "/tmp/aionui-team-missing-leader-workspace" }), ) .unwrap(); @@ -2687,7 +2687,7 @@ async fn add_agent_continues_when_team_temp_leader_patch_fails() { force_team_workspace(&team_repo, &created.id, "").await; conv_repo .patch_extra( - &created.agents[0].conversation_id, + &created.assistants[0].conversation_id, serde_json::json!({ "workspace": "/tmp/aionui-team-missing-leader-workspace" }), ) .unwrap(); @@ -2739,7 +2739,7 @@ async fn provisioning_writes_typed_team_binding_for_create_and_add_agent() { .await .unwrap(); - for agent in &created.agents { + for agent in &created.assistants { let extra = conv_repo.get_extra(&agent.conversation_id).unwrap(); assert_eq!(extra.get("teamId").and_then(|v| v.as_str()), Some(created.id.as_str())); assert_eq!( @@ -2824,12 +2824,12 @@ async fn ar1_remove_agent_from_team() { .await .unwrap(); - let worker_slot = created.agents[1].slot_id.clone(); + let worker_slot = created.assistants[1].slot_id.clone(); svc.remove_agent("user1", &created.id, &worker_slot).await.unwrap(); let got = svc.get_team("user1", &created.id).await.unwrap(); - assert_eq!(got.agents.len(), 1); - assert!(got.agents.iter().all(|a| a.slot_id != worker_slot)); + assert_eq!(got.assistants.len(), 1); + assert!(got.assistants.iter().all(|a| a.slot_id != worker_slot)); } #[tokio::test] @@ -2866,13 +2866,13 @@ async fn an1_rename_agent() { .await .unwrap(); - let slot_id = created.agents[1].slot_id.clone(); + let slot_id = created.assistants[1].slot_id.clone(); svc.rename_agent("user1", &created.id, &slot_id, "Senior Worker") .await .unwrap(); let got = svc.get_team("user1", &created.id).await.unwrap(); - let agent = got.agents.iter().find(|a| a.slot_id == slot_id).unwrap(); + let agent = got.assistants.iter().find(|a| a.slot_id == slot_id).unwrap(); assert_eq!(agent.name, "Senior Worker"); } @@ -2936,7 +2936,7 @@ async fn spawn_agent_in_session_rejects_without_active_team_run_before_persistin .await .expect("session should be loaded without active Team Run"); let lead_slot_id = created - .lead_agent_id + .leader_assistant_id .clone() .expect("created team should have a lead slot"); @@ -2962,8 +2962,8 @@ async fn spawn_agent_in_session_rejects_without_active_team_run_before_persistin .await .expect("team should still be readable"); assert_eq!( - after.agents.len(), - created.agents.len(), + after.assistants.len(), + created.assistants.len(), "failed spawn must not persist a partial teammate" ); } @@ -3168,7 +3168,7 @@ async fn sa_send_message_to_agent_with_active_session() { .unwrap(); svc.ensure_session("user1", &created.id).await.unwrap(); - let worker_slot = created.agents[1].slot_id.clone(); + let worker_slot = created.assistants[1].slot_id.clone(); svc.send_message_to_agent("user1", &created.id, &worker_slot, "Do this", None) .await .unwrap(); @@ -3188,7 +3188,7 @@ async fn sa2_send_message_to_agent_rejects_cross_user_access() { ) .await .unwrap(); - let worker_slot = created.agents[1].slot_id.clone(); + let worker_slot = created.assistants[1].slot_id.clone(); let result = svc .send_message_to_agent("user2", &created.id, &worker_slot, "Do this", None) @@ -3312,7 +3312,7 @@ async fn d9_ensure_session_kills_and_rebuilds_every_agent() { let calls = tm.snapshot(); assert_eq!(calls.kill.len(), 2, "expected 2 kill calls"); assert_eq!(calls.build.len(), 2, "expected 2 build calls"); - for (i, agent) in created.agents.iter().enumerate() { + for (i, agent) in created.assistants.iter().enumerate() { assert_eq!(calls.kill[i].0, agent.conversation_id); assert_eq!(calls.kill[i].1, Some(AgentKillReason::TeamMcpRebuild)); assert_eq!(calls.build[i], agent.conversation_id); @@ -3509,11 +3509,11 @@ async fn w4_d23_concurrent_add_agent_preserves_every_insertion() { let got = svc.get_team("user1", &created.id).await.unwrap(); assert_eq!( - got.agents.len(), + got.assistants.len(), 3, "both concurrent add_agent calls must be persisted (1 lead + 2 workers)" ); - let names: std::collections::HashSet<_> = got.agents.iter().map(|a| a.name.clone()).collect(); + let names: std::collections::HashSet<_> = got.assistants.iter().map(|a| a.name.clone()).collect(); assert!(names.contains("Lead")); assert!(names.contains("WorkerA")); assert!(names.contains("WorkerB")); @@ -3549,10 +3549,10 @@ async fn d115_remove_team_kills_every_agent_process() { let new_kills = &calls.kill[before_kill..]; assert_eq!( new_kills.len(), - created.agents.len(), + created.assistants.len(), "remove_team must kill every agent once" ); - for (i, agent) in created.agents.iter().enumerate() { + for (i, agent) in created.assistants.iter().enumerate() { assert_eq!(new_kills[i].0, agent.conversation_id); assert_eq!(new_kills[i].1, Some(AgentKillReason::TeamDeleted)); } From cffae6d780326589c4b0e6e5f102fe344edd2941 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 12:36:08 +0800 Subject: [PATCH 089/135] test(team): align app e2e with assistant responses --- crates/aionui-app/tests/team_e2e.rs | 79 ++++++++++++++------------- crates/aionui-team/tests/e2e_smoke.rs | 2 +- 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/crates/aionui-app/tests/team_e2e.rs b/crates/aionui-app/tests/team_e2e.rs index 56b4e0fc8..c209c268f 100644 --- a/crates/aionui-app/tests/team_e2e.rs +++ b/crates/aionui-app/tests/team_e2e.rs @@ -65,7 +65,7 @@ async fn create_team(app: &mut axum::Router, token: &str, csrf: &str) -> serde_j // §1 Team CRUD (TC-*, TL-*, TG-*, TD-*, TR-*) // =========================================================================== -// TC-1: Create team with multiple agents +// TC-1: Create team with multiple assistants #[tokio::test] async fn tc1_create_team_with_multiple_agents() { let (mut app, services) = build_app().await; @@ -73,14 +73,14 @@ async fn tc1_create_team_with_multiple_agents() { let data = create_team(&mut app, &token, &csrf).await; assert_eq!(data["name"], "Alpha"); - assert_eq!(data["agents"].as_array().unwrap().len(), 2); - assert_eq!(data["agents"][0]["role"], "lead"); - assert_eq!(data["agents"][1]["role"], "teammate"); - assert!(data["lead_agent_id"].is_string()); - assert_eq!(data["lead_agent_id"], data["agents"][0]["slot_id"]); + assert_eq!(data["assistants"].as_array().unwrap().len(), 2); + assert_eq!(data["assistants"][0]["role"], "lead"); + assert_eq!(data["assistants"][1]["role"], "teammate"); + assert!(data["leader_assistant_id"].is_string()); + assert_eq!(data["leader_assistant_id"], data["assistants"][0]["slot_id"]); } -// TC-2: Create single agent team +// TC-2: Create single assistant team #[tokio::test] async fn tc2_create_single_agent_team() { let (mut app, services) = build_app().await; @@ -95,23 +95,23 @@ async fn tc2_create_single_agent_team() { let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let json = body_json(resp).await; - assert_eq!(json["data"]["agents"].as_array().unwrap().len(), 1); + assert_eq!(json["data"]["assistants"].as_array().unwrap().len(), 1); } -// TC-3: Each agent has a conversation +// TC-3: Each assistant has a conversation #[tokio::test] async fn tc3_each_agent_has_conversation_id() { let (mut app, services) = build_app().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; let data = create_team(&mut app, &token, &csrf).await; - for agent in data["agents"].as_array().unwrap() { + for agent in data["assistants"].as_array().unwrap() { assert!(agent["conversation_id"].is_string()); assert!(!agent["conversation_id"].as_str().unwrap().is_empty()); } assert_ne!( - data["agents"][0]["conversation_id"], - data["agents"][1]["conversation_id"] + data["assistants"][0]["conversation_id"], + data["assistants"][1]["conversation_id"] ); } @@ -121,7 +121,7 @@ async fn tc3b_create_team_writes_legacy_extra_shape() { let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; let data = create_team(&mut app, &token, &csrf).await; - let conversation_id = data["agents"][0]["conversation_id"].as_str().unwrap(); + let conversation_id = data["assistants"][0]["conversation_id"].as_str().unwrap(); let repo = aionui_db::SqliteConversationRepository::new(services.database.pool().clone()); let row = repo.get(conversation_id).await.unwrap().unwrap(); @@ -135,7 +135,7 @@ async fn tc3b_create_team_writes_legacy_extra_shape() { assert_eq!(extra["current_model_id"], "claude"); } -// TC-4: First agent defaults to lead +// TC-4: First assistant defaults to lead #[tokio::test] async fn tc4_first_agent_is_lead() { let (mut app, services) = build_app().await; @@ -152,8 +152,11 @@ async fn tc4_first_agent_is_lead() { let req = json_with_token("POST", "/api/teams", body, &token, &csrf); let resp = app.oneshot(req).await.unwrap(); let json = body_json(resp).await; - assert_eq!(json["data"]["agents"][0]["role"], "lead"); - assert_eq!(json["data"]["lead_agent_id"], json["data"]["agents"][0]["slot_id"]); + assert_eq!(json["data"]["assistants"][0]["role"], "lead"); + assert_eq!( + json["data"]["leader_assistant_id"], + json["data"]["assistants"][0]["slot_id"] + ); } // TC-5: Empty agents returns 400 @@ -303,7 +306,7 @@ async fn team_api_rejects_cross_user_access() { let data = create_team(&mut app, &owner_token, &owner_csrf).await; let team_id = data["id"].as_str().unwrap(); - let slot_id = data["agents"][1]["slot_id"].as_str().unwrap(); + let slot_id = data["assistants"][1]["slot_id"].as_str().unwrap(); let req = get_with_token("/api/teams", &other_token); let resp = app.clone().oneshot(req).await.unwrap(); @@ -372,7 +375,7 @@ async fn pause_team_slot_endpoint_requires_owned_team_and_active_run() { let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; let data = create_team(&mut app, &token, &csrf).await; let team_id = data["id"].as_str().unwrap(); - let lead_slot_id = data["agents"][0]["slot_id"].as_str().unwrap(); + let lead_slot_id = data["assistants"][0]["slot_id"].as_str().unwrap(); let req = json_with_token( "POST", @@ -388,7 +391,7 @@ async fn pause_team_slot_endpoint_requires_owned_team_and_active_run() { assert!(body["success"].as_bool().is_some_and(|success| !success)); } -// TL-3: Each team contains full agents info +// TL-3: Each team contains full assistants info #[tokio::test] async fn tl3_teams_contain_full_agent_info() { let (mut app, services) = build_app().await; @@ -400,7 +403,7 @@ async fn tl3_teams_contain_full_agent_info() { let resp = app.oneshot(req).await.unwrap(); let json = body_json(resp).await; let teams = json["data"].as_array().unwrap(); - let agent = &teams[0]["agents"][0]; + let agent = &teams[0]["assistants"][0]; assert!(agent["slot_id"].is_string()); assert!(agent["name"].is_string()); assert!(agent["role"].is_string()); @@ -589,7 +592,7 @@ async fn aa2_add_agent_increases_count() { let req = get_with_token(&format!("/api/teams/{team_id}"), &token); let resp = app.oneshot(req).await.unwrap(); let json = body_json(resp).await; - assert_eq!(json["data"]["agents"].as_array().unwrap().len(), 3); + assert_eq!(json["data"]["assistants"].as_array().unwrap().len(), 3); } // AA-4: Add agent to nonexistent team returns 404 @@ -632,7 +635,7 @@ async fn ar1_remove_agent_from_team() { let data = create_team(&mut app, &token, &csrf).await; let team_id = data["id"].as_str().unwrap(); - let slot_id = data["agents"][1]["slot_id"].as_str().unwrap(); + let slot_id = data["assistants"][1]["slot_id"].as_str().unwrap(); let req = delete_with_token(&format!("/api/teams/{team_id}/agents/{slot_id}"), &token, &csrf); let resp = app.clone().oneshot(req).await.unwrap(); @@ -647,7 +650,7 @@ async fn ar2_after_removal_agent_gone() { let data = create_team(&mut app, &token, &csrf).await; let team_id = data["id"].as_str().unwrap(); - let slot_id = data["agents"][1]["slot_id"].as_str().unwrap(); + let slot_id = data["assistants"][1]["slot_id"].as_str().unwrap(); let req = delete_with_token(&format!("/api/teams/{team_id}/agents/{slot_id}"), &token, &csrf); app.clone().oneshot(req).await.unwrap(); @@ -655,9 +658,9 @@ async fn ar2_after_removal_agent_gone() { let req = get_with_token(&format!("/api/teams/{team_id}"), &token); let resp = app.oneshot(req).await.unwrap(); let json = body_json(resp).await; - let agents = json["data"]["agents"].as_array().unwrap(); - assert_eq!(agents.len(), 1); - assert!(agents.iter().all(|a| a["slot_id"] != slot_id)); + let assistants = json["data"]["assistants"].as_array().unwrap(); + assert_eq!(assistants.len(), 1); + assert!(assistants.iter().all(|a| a["slot_id"] != slot_id)); } // AR-4: Remove nonexistent agent returns 404 @@ -682,7 +685,7 @@ async fn an1_rename_agent() { let data = create_team(&mut app, &token, &csrf).await; let team_id = data["id"].as_str().unwrap(); - let slot_id = data["agents"][1]["slot_id"].as_str().unwrap(); + let slot_id = data["assistants"][1]["slot_id"].as_str().unwrap(); let req = json_with_token( "PATCH", @@ -703,7 +706,7 @@ async fn an2_rename_then_get_confirms_name() { let data = create_team(&mut app, &token, &csrf).await; let team_id = data["id"].as_str().unwrap(); - let slot_id = data["agents"][1]["slot_id"].as_str().unwrap(); + let slot_id = data["assistants"][1]["slot_id"].as_str().unwrap(); let req = json_with_token( "PATCH", @@ -717,8 +720,8 @@ async fn an2_rename_then_get_confirms_name() { let req = get_with_token(&format!("/api/teams/{team_id}"), &token); let resp = app.oneshot(req).await.unwrap(); let json = body_json(resp).await; - let agents = json["data"]["agents"].as_array().unwrap(); - let agent = agents.iter().find(|a| a["slot_id"] == slot_id).unwrap(); + let assistants = json["data"]["assistants"].as_array().unwrap(); + let agent = assistants.iter().find(|a| a["slot_id"] == slot_id).unwrap(); assert_eq!(agent["name"], "Senior Worker"); } @@ -885,7 +888,7 @@ async fn sm1b_team_send_persists_user_bubble_through_projection_adapter() { let data = create_team(&mut app, &token, &csrf).await; let team_id = data["id"].as_str().unwrap(); - let lead_conversation_id = data["agents"][0]["conversation_id"].as_str().unwrap(); + let lead_conversation_id = data["assistants"][0]["conversation_id"].as_str().unwrap(); let req = json_with_token( "POST", @@ -915,7 +918,7 @@ async fn sm1c_team_owned_conversation_regular_send_is_forbidden() { let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; let data = create_team(&mut app, &token, &csrf).await; - let conversation_id = data["agents"][0]["conversation_id"].as_str().unwrap(); + let conversation_id = data["assistants"][0]["conversation_id"].as_str().unwrap(); let req = json_with_token( "POST", @@ -994,7 +997,7 @@ async fn sa1_send_message_to_agent() { let data = create_team(&mut app, &token, &csrf).await; let team_id = data["id"].as_str().unwrap(); - let slot_id = data["agents"][1]["slot_id"].as_str().unwrap(); + let slot_id = data["assistants"][1]["slot_id"].as_str().unwrap(); // Start session first let req = json_with_token( @@ -1030,7 +1033,7 @@ async fn full_team_lifecycle() { // Create let data = create_team(&mut app, &token, &csrf).await; let team_id = data["id"].as_str().unwrap(); - assert_eq!(data["agents"].as_array().unwrap().len(), 2); + assert_eq!(data["assistants"].as_array().unwrap().len(), 2); // Add agent let body = json!({ @@ -1045,11 +1048,11 @@ async fn full_team_lifecycle() { let added = body_json(resp).await; let new_slot = added["data"]["slot_id"].as_str().unwrap().to_owned(); - // Verify 3 agents + // Verify 3 assistants let req = get_with_token(&format!("/api/teams/{team_id}"), &token); let resp = app.clone().oneshot(req).await.unwrap(); let json = body_json(resp).await; - assert_eq!(json["data"]["agents"].as_array().unwrap().len(), 3); + assert_eq!(json["data"]["assistants"].as_array().unwrap().len(), 3); // Rename team let req = json_with_token( @@ -1099,11 +1102,11 @@ async fn full_team_lifecycle() { let req = delete_with_token(&format!("/api/teams/{team_id}/agents/{new_slot}"), &token, &csrf); app.clone().oneshot(req).await.unwrap(); - // Verify 2 agents remain + // Verify 2 assistants remain let req = get_with_token(&format!("/api/teams/{team_id}"), &token); let resp = app.clone().oneshot(req).await.unwrap(); let json = body_json(resp).await; - assert_eq!(json["data"]["agents"].as_array().unwrap().len(), 2); + assert_eq!(json["data"]["assistants"].as_array().unwrap().len(), 2); assert_eq!(json["data"]["name"], "Renamed"); // Delete team diff --git a/crates/aionui-team/tests/e2e_smoke.rs b/crates/aionui-team/tests/e2e_smoke.rs index 60ec99c85..ddfc18a8e 100644 --- a/crates/aionui-team/tests/e2e_smoke.rs +++ b/crates/aionui-team/tests/e2e_smoke.rs @@ -193,7 +193,7 @@ fn is_error_response(resp: &Value) -> bool { /// /// Flow: /// 1. `TeamSessionService::create_team` with a lead + one worker. -/// 2. Assert the returned team has a `lead_agent_id` and two agents. +/// 2. Assert the returned team has a `leader_assistant_id` and two assistants. /// 3. Assert `TeamMcpServer` is started for that team (ensure_session). /// 4. `tools/list` returns the full 10-tool surface. /// 5. `team_members` returns both agents. From 5250f35be9850b31bf1e1db4ec9099699623f744 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 13:08:39 +0800 Subject: [PATCH 090/135] refactor(team): omit agent types for assistant conversations --- crates/aionui-app/src/router/team_conversation_adapters.rs | 6 +----- crates/aionui-team/src/provisioning.rs | 4 ++-- crates/aionui-team/src/test_utils.rs | 2 +- crates/aionui-team/tests/session_service_integration.rs | 7 ++++++- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/aionui-app/src/router/team_conversation_adapters.rs b/crates/aionui-app/src/router/team_conversation_adapters.rs index 6e2c6d2a1..897ce1442 100644 --- a/crates/aionui-app/src/router/team_conversation_adapters.rs +++ b/crates/aionui-app/src/router/team_conversation_adapters.rs @@ -151,11 +151,7 @@ impl TeamConversationProvisioningPort for TeamConversationAdapters { .create( &request.user_id, CreateConversationRequest { - r#type: if request.assistant_id.is_some() { - None - } else { - Some(request.agent_type) - }, + r#type: request.agent_type, name: Some(request.name), model: request.top_level_model, assistant: request.assistant_id.map(|assistant_id| AssistantConversationRequest { diff --git a/crates/aionui-team/src/provisioning.rs b/crates/aionui-team/src/provisioning.rs index 6e8847c93..5e5f5984d 100644 --- a/crates/aionui-team/src/provisioning.rs +++ b/crates/aionui-team/src/provisioning.rs @@ -50,7 +50,7 @@ struct NewAgentProvisioning { pub struct TeamConversationCreateRequest { pub user_id: String, - pub agent_type: AgentType, + pub agent_type: Option, pub name: String, pub top_level_model: Option, pub assistant_id: Option, @@ -482,7 +482,7 @@ impl TeamAgentProvisioner { .conversation_port .create_team_conversation(TeamConversationCreateRequest { user_id: user_id.to_owned(), - agent_type, + agent_type: if assistant_id.is_some() { None } else { Some(agent_type) }, name: name.to_owned(), top_level_model, assistant_id: assistant_id.map(str::to_owned), diff --git a/crates/aionui-team/src/test_utils.rs b/crates/aionui-team/src/test_utils.rs index cd4d0f353..a0ffe0ac5 100644 --- a/crates/aionui-team/src/test_utils.rs +++ b/crates/aionui-team/src/test_utils.rs @@ -536,7 +536,7 @@ pub(crate) mod workspace_harness { id: id.clone(), user_id: request.user_id, name: request.name, - r#type: request.agent_type.serde_name().to_owned(), + r#type: request.agent_type.unwrap_or(AgentType::Acp).serde_name().to_owned(), pinned: false, pinned_at: None, source: None, diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index 7dd845fab..ac488dfe2 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -271,6 +271,11 @@ impl TeamConversationProvisioningPort for FakeConversationPorts { &self, request: TeamConversationCreateRequest, ) -> Result { + if request.assistant_id.is_some() && request.agent_type.is_some() { + return Err(aionui_team::TeamError::InvalidRequest( + "assistant-backed team conversations must not provide agent_type".into(), + )); + } let id = aionui_common::generate_id(); let now = aionui_common::now_ms(); let workspace = request @@ -291,7 +296,7 @@ impl TeamConversationProvisioningPort for FakeConversationPorts { id: id.clone(), user_id: request.user_id, name: request.name, - r#type: request.agent_type.serde_name().to_owned(), + r#type: request.agent_type.unwrap_or(AgentType::Acp).serde_name().to_owned(), pinned: false, pinned_at: None, source: None, From c9f52286f08e669f1eea13fdfb897b4addf327c5 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 13:51:09 +0800 Subject: [PATCH 091/135] refactor(channel): reject legacy assistant write fields --- crates/aionui-api-types/src/channel.rs | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/aionui-api-types/src/channel.rs b/crates/aionui-api-types/src/channel.rs index 7c66cdefb..df84a53fd 100644 --- a/crates/aionui-api-types/src/channel.rs +++ b/crates/aionui-api-types/src/channel.rs @@ -92,6 +92,7 @@ pub struct SyncChannelSettingsRequest { /// New writes must use assistant identity only. Legacy backend / agent fields /// remain readable in responses for historical settings migration. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] pub struct ChannelAssistantSettingRequest { pub assistant_id: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -814,4 +815,34 @@ mod tests { let parsed: ChannelSessionResponse = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, resp); } + + #[test] + fn test_channel_assistant_setting_request_rejects_legacy_backend_fields() { + let err = serde_json::from_str::( + r#"{"assistant_id":"bare-codex","backend":"codex","name":"Codex"}"#, + ) + .unwrap_err(); + + assert!(err.to_string().contains("unknown field `backend`"), "{err}"); + } + + #[test] + fn test_channel_assistant_setting_request_rejects_legacy_custom_agent_fields() { + let err = serde_json::from_str::( + r#"{"assistant_id":"legacy-custom","custom_agent_id":"legacy-custom","name":"Codex"}"#, + ) + .unwrap_err(); + + assert!(err.to_string().contains("unknown field `custom_agent_id`"), "{err}"); + } + + #[test] + fn test_channel_assistant_setting_request_rejects_legacy_agent_type_fields() { + let err = serde_json::from_str::( + r#"{"assistant_id":"bare-codex","agent_type":"acp","name":"Codex"}"#, + ) + .unwrap_err(); + + assert!(err.to_string().contains("unknown field `agent_type`"), "{err}"); + } } From 2eb255272e7af9da9609dd8811c9294018fe76d2 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 14:15:31 +0800 Subject: [PATCH 092/135] refactor(team): reject backend in public write dto --- crates/aionui-api-types/src/team.rs | 50 ++++++++++++++++++---------- crates/aionui-team/src/test_utils.rs | 2 +- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/crates/aionui-api-types/src/team.rs b/crates/aionui-api-types/src/team.rs index 8c8f8a969..5f588363e 100644 --- a/crates/aionui-api-types/src/team.rs +++ b/crates/aionui-api-types/src/team.rs @@ -30,8 +30,6 @@ pub struct TeamAgentInput { #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] struct TeamAgentInputCompat { - #[serde(default)] - pub backend: Option, #[serde(default)] pub assistant_id: Option, pub name: String, @@ -59,7 +57,7 @@ impl<'de> Deserialize<'de> for TeamAgentInput { Ok(Self { name: raw.name, role: raw.role, - backend: raw.backend, + backend: None, model: raw.model, assistant_id: Some(assistant_id), conversation_id: raw.conversation_id, @@ -113,8 +111,6 @@ struct AddAgentRequestCompat { #[serde(default)] role: Option, #[serde(default)] - backend: Option, - #[serde(default)] model: Option, #[serde(default)] assistant_id: Option, @@ -130,7 +126,7 @@ impl<'de> Deserialize<'de> for AddAgentRequest { return Ok(Self { name: assistant.name, role: assistant.role, - backend: assistant.backend, + backend: None, model: assistant.model, assistant_id: assistant.assistant_id, }); @@ -145,7 +141,7 @@ impl<'de> Deserialize<'de> for AddAgentRequest { Ok(Self { name, role, - backend: raw.backend, + backend: None, model, assistant_id: Some(assistant_id), }) @@ -528,14 +524,12 @@ mod tests { { "name": "Lead", "role": "lead", - "backend": "acp", "model": "claude", "assistant_id": "assistant-x" }, { "name": "Worker", "role": "teammate", - "backend": "acp", "model": "claude", "assistant_id": "assistant-y" } @@ -546,7 +540,7 @@ mod tests { assert_eq!(req.agents.len(), 2); assert_eq!(req.agents[0].name, "Lead"); assert_eq!(req.agents[0].role, "lead"); - assert_eq!(req.agents[0].backend.as_deref(), Some("acp")); + assert!(req.agents[0].backend.is_none()); assert_eq!(req.agents[0].model, "claude"); assert_eq!(req.agents[0].assistant_id.as_deref(), Some("assistant-x")); assert_eq!(req.agents[1].name, "Worker"); @@ -579,7 +573,6 @@ mod tests { let raw = json!({ "name": "Lead", "role": "lead", - "backend": "acp", "model": "claude", "assistant_id": "assistant-x", "conversation_id": "existing-conv-123" @@ -606,7 +599,6 @@ mod tests { let raw = json!({ "name": "Lead", "role": "lead", - "backend": "acp", "model": "claude", "assistant_id": "assistant-x" }); @@ -627,6 +619,19 @@ mod tests { assert_eq!(input.assistant_id.as_deref(), Some("assistant-x")); } + #[test] + fn deserialize_team_agent_input_rejects_backend_field() { + let raw = json!({ + "name": "Lead", + "role": "lead", + "backend": "acp", + "model": "claude", + "assistant_id": "assistant-x" + }); + let result = serde_json::from_value::(raw); + assert!(result.is_err()); + } + #[test] fn deserialize_team_agent_input_requires_assistant_id() { let raw = json!({ @@ -681,14 +686,13 @@ mod tests { let raw = json!({ "name": "Helper", "role": "teammate", - "backend": "acp", "model": "claude", "assistant_id": "assistant-1" }); let req: AddAgentRequest = serde_json::from_value(raw).unwrap(); assert_eq!(req.name, "Helper"); assert_eq!(req.role, "teammate"); - assert_eq!(req.backend.as_deref(), Some("acp")); + assert!(req.backend.is_none()); assert_eq!(req.model, "claude"); assert_eq!(req.assistant_id.as_deref(), Some("assistant-1")); } @@ -698,7 +702,6 @@ mod tests { let raw = json!({ "name": "Custom", "role": "teammate", - "backend": "acp", "model": "claude", "custom_agent_id": "custom-1" }); @@ -711,7 +714,6 @@ mod tests { let raw = json!({ "name": "Custom", "role": "teammate", - "backend": "acp", "model": "claude", "assistant_id": "assistant-1" }); @@ -741,7 +743,6 @@ mod tests { fn deserialize_add_agent_request_missing_name() { let raw = json!({ "role": "teammate", - "backend": "acp", "model": "claude", "assistant_id": "assistant-1" }); @@ -751,7 +752,7 @@ mod tests { #[test] fn deserialize_add_agent_request_requires_assistant_id() { - let raw = json!({ "name": "X", "role": "teammate", "backend": "acp", "model": "claude" }); + let raw = json!({ "name": "X", "role": "teammate", "model": "claude" }); let result = serde_json::from_value::(raw); assert!(result.is_err()); } @@ -769,6 +770,19 @@ mod tests { assert_eq!(req.assistant_id.as_deref(), Some("assistant-1")); } + #[test] + fn deserialize_add_agent_request_rejects_backend_field() { + let raw = json!({ + "name": "X", + "role": "teammate", + "backend": "acp", + "model": "claude", + "assistant_id": "assistant-1" + }); + let result = serde_json::from_value::(raw); + assert!(result.is_err()); + } + #[test] fn deserialize_rename_agent_request() { let raw = json!({ "name": "New Agent Name" }); diff --git a/crates/aionui-team/src/test_utils.rs b/crates/aionui-team/src/test_utils.rs index a0ffe0ac5..7eba647b4 100644 --- a/crates/aionui-team/src/test_utils.rs +++ b/crates/aionui-team/src/test_utils.rs @@ -194,7 +194,7 @@ pub(crate) mod workspace_harness { use aionui_ai_agent::{AgentError, IWorkerTaskManager}; use aionui_api_types::{CreateTeamRequest, WebSocketMessage}; - use aionui_common::{AgentKillReason, PaginatedResult, now_ms}; + use aionui_common::{AgentKillReason, AgentType, PaginatedResult, now_ms}; use aionui_db::models::{ AgentMetadataRow, AssistantDefinitionRow, AssistantOverlayRow, ConversationRow, MessageRow, TeamRow, TeamTaskRow, UpdateAgentHandshakeParams, UpsertAgentMetadataParams, UpsertAssistantDefinitionParams, From 5a9ec236e7bc2be36eb5ab86b5d710d9e7802357 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 14:23:13 +0800 Subject: [PATCH 093/135] refactor(cron): split read and write agent config dto --- crates/aionui-api-types/src/cron.rs | 18 +++++++++--------- crates/aionui-api-types/src/lib.rs | 2 +- crates/aionui-cron/src/types.rs | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/aionui-api-types/src/cron.rs b/crates/aionui-api-types/src/cron.rs index 0b209b4b5..5053f98f4 100644 --- a/crates/aionui-api-types/src/cron.rs +++ b/crates/aionui-api-types/src/cron.rs @@ -37,7 +37,7 @@ pub enum CronScheduleDto { // --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct CronAgentConfigDto { +pub struct CronAgentConfigReadDto { #[serde(default, skip_serializing_if = "Option::is_none")] pub backend: Option, pub name: String, @@ -109,7 +109,7 @@ pub struct CronJobMetadataDto { pub created_at: TimestampMs, pub updated_at: TimestampMs, #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_config: Option, + pub agent_config: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -433,7 +433,7 @@ mod tests { } } - // -- B. CronAgentConfigDto ------------------------------------------------ + // -- B. CronAgentConfigReadDto ------------------------------------------- #[test] fn agent_config_full() { @@ -450,7 +450,7 @@ mod tests { "config_options": {"key": "value"}, "workspace": "/tmp/ws" }); - let c: CronAgentConfigDto = serde_json::from_value(raw).unwrap(); + let c: CronAgentConfigReadDto = serde_json::from_value(raw).unwrap(); assert_eq!(c.backend.as_deref(), Some("acp")); assert_eq!(c.name, "Claude Agent"); assert_eq!(c.cli_path.as_deref(), Some("/usr/bin/claude")); @@ -464,7 +464,7 @@ mod tests { #[test] fn agent_config_minimal() { let raw = json!({"backend": "openai", "name": "GPT"}); - let c: CronAgentConfigDto = serde_json::from_value(raw).unwrap(); + let c: CronAgentConfigReadDto = serde_json::from_value(raw).unwrap(); assert_eq!(c.backend.as_deref(), Some("openai")); assert_eq!(c.name, "GPT"); assert!(c.cli_path.is_none()); @@ -474,7 +474,7 @@ mod tests { #[test] fn agent_config_serialize_omits_none() { - let c = CronAgentConfigDto { + let c = CronAgentConfigReadDto { backend: Some("acp".into()), name: "Test".into(), cli_path: None, @@ -495,7 +495,7 @@ mod tests { #[test] fn agent_config_roundtrip() { - let c = CronAgentConfigDto { + let c = CronAgentConfigReadDto { backend: Some("acp".into()), name: "Agent".into(), cli_path: Some("/bin/x".into()), @@ -509,7 +509,7 @@ mod tests { workspace: Some("/ws".into()), }; let json = serde_json::to_string(&c).unwrap(); - let parsed: CronAgentConfigDto = serde_json::from_str(&json).unwrap(); + let parsed: CronAgentConfigReadDto = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, c); } @@ -539,7 +539,7 @@ mod tests { created_by: "user".into(), created_at: 1700000000000, updated_at: 1700001000000, - agent_config: Some(CronAgentConfigDto { + agent_config: Some(CronAgentConfigReadDto { backend: Some("acp".into()), name: "Claude".into(), cli_path: None, diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index 508204378..0a32e800a 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -83,7 +83,7 @@ pub use conversation::{ UpdateConversationArtifactRequest, UpdateConversationRequest, }; pub use cron::{ - CreateCronJobRequest, CronAgentConfigDto, CronAgentConfigWriteDto, CronJobExecutedEvent, CronJobMetadataDto, + CreateCronJobRequest, CronAgentConfigReadDto, CronAgentConfigWriteDto, CronJobExecutedEvent, CronJobMetadataDto, CronJobPayloadDto, CronJobRemovedPayload, CronJobResponse, CronJobStateDto, CronJobTargetDto, CronScheduleDto, HasSkillResponse, ListCronJobsQuery, RunNowResponse, SaveCronSkillRequest, UpdateCronJobRequest, }; diff --git a/crates/aionui-cron/src/types.rs b/crates/aionui-cron/src/types.rs index 587d6795d..06e2cbec5 100644 --- a/crates/aionui-cron/src/types.rs +++ b/crates/aionui-cron/src/types.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::str::FromStr; use aionui_api_types::{ - CronAgentConfigDto, CronJobMetadataDto, CronJobPayloadDto, CronJobResponse, CronJobStateDto, CronJobTargetDto, + CronAgentConfigReadDto, CronJobMetadataDto, CronJobPayloadDto, CronJobResponse, CronJobStateDto, CronJobTargetDto, CronScheduleDto, }; use aionui_common::TimestampMs; @@ -335,7 +335,7 @@ pub fn cron_job_to_response(job: &CronJob) -> CronJobResponse { let canonical_assistant_id = c.assistant_id.clone().or_else(|| c.custom_agent_id.clone()); let assistant_backed = canonical_assistant_id.is_some(); let preserve_backend = !assistant_backed || job.agent_type == "aionrs"; - CronAgentConfigDto { + CronAgentConfigReadDto { backend: preserve_backend.then(|| c.backend.clone()), name: c.name.clone(), cli_path: if assistant_backed { None } else { c.cli_path.clone() }, From 40f2a316ddd2ca1be9013e332d629a9c7da5bf17 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 14:47:44 +0800 Subject: [PATCH 094/135] refactor(channel): reject unresolved assistant bindings --- crates/aionui-channel/src/channel_settings.rs | 45 +++++++++---- crates/aionui-channel/src/message_service.rs | 6 +- .../tests/message_service_integration.rs | 65 +++++++++++++++++-- 3 files changed, 99 insertions(+), 17 deletions(-) diff --git a/crates/aionui-channel/src/channel_settings.rs b/crates/aionui-channel/src/channel_settings.rs index 05ae76316..f102c6c9a 100644 --- a/crates/aionui-channel/src/channel_settings.rs +++ b/crates/aionui-channel/src/channel_settings.rs @@ -77,17 +77,21 @@ impl ChannelSettingsService { }; if let Some(setting) = parse_channel_assistant_setting(&pref.value) { - if let Some(assistant_id) = setting.assistant_id.as_deref() - && let Some(resolved) = self.resolve_assistant_agent_config(assistant_id).await? - { - debug!( - platform = %platform, - assistant_id, - agent_type = %resolved.agent_type, - backend = ?resolved.backend, - "resolved channel agent config from assistant identity" - ); - return Ok(resolved); + if let Some(assistant_id) = setting.assistant_id.as_deref() { + if let Some(resolved) = self.resolve_assistant_agent_config(assistant_id).await? { + debug!( + platform = %platform, + assistant_id, + agent_type = %resolved.agent_type, + backend = ?resolved.backend, + "resolved channel agent config from assistant identity" + ); + return Ok(resolved); + } + + return Err(ChannelError::InvalidConfig(format!( + "Channel assistant binding references unresolved assistant identity: {assistant_id}" + ))); } if let Some(at) = setting.agent_type.as_deref() { @@ -742,6 +746,25 @@ mod tests { assert_eq!(config.backend.as_deref(), Some("codex")); } + #[tokio::test] + async fn agent_config_errors_when_assistant_identity_cannot_resolve() { + let repo = Arc::new(MockPrefRepo::with_data(vec![( + "assistant.telegram.agent", + r#"{"assistant_id":"missing-assistant","name":"Missing"}"#, + )])); + let definition_repo: Arc = + Arc::new(MockAssistantDefinitionRepo { rows: vec![] }); + let overlay_repo: Arc = Arc::new(MockAssistantOverlayRepo { rows: vec![] }); + let svc = ChannelSettingsService::new(repo).with_assistant_repos(definition_repo, overlay_repo); + + let err = svc.get_agent_config(PluginType::Telegram).await.unwrap_err(); + assert!(matches!(err, ChannelError::InvalidConfig(_))); + assert!( + err.to_string().contains("missing-assistant"), + "error should name the unresolved assistant identity" + ); + } + // ── get_model_config ────────────────────────────────────────────── #[tokio::test] diff --git a/crates/aionui-channel/src/message_service.rs b/crates/aionui-channel/src/message_service.rs index 584c214f5..37b627fbf 100644 --- a/crates/aionui-channel/src/message_service.rs +++ b/crates/aionui-channel/src/message_service.rs @@ -125,7 +125,11 @@ impl ChannelMessageService { platform: PluginType, ) -> Result { let source = platform_to_source(platform); - let agent_config = self.settings.get_agent_config(platform).await?; + let agent_config = self + .settings + .get_agent_config(platform) + .await + .map_err(|e| ChannelError::MessageSendFailed(e.to_string()))?; let assistant_setting = self.settings.get_assistant_setting(platform).await?; let assistant_id = assistant_setting .as_ref() diff --git a/crates/aionui-channel/tests/message_service_integration.rs b/crates/aionui-channel/tests/message_service_integration.rs index b54722f08..5004d59af 100644 --- a/crates/aionui-channel/tests/message_service_integration.rs +++ b/crates/aionui-channel/tests/message_service_integration.rs @@ -6,6 +6,7 @@ use aionui_ai_agent::types::{BuildTaskOptions, SendMessageData}; use aionui_ai_agent::{AgentError, AgentSendError, AgentStreamEvent, IMockAgent, IWorkerTaskManager}; use aionui_api_types::WebSocketMessage; use aionui_channel::channel_settings::ChannelSettingsService; +use aionui_channel::error::ChannelError; use aionui_channel::message_service::ChannelMessageService; use aionui_channel::types::PluginType; use aionui_common::{AgentKillReason, AgentType, ConversationStatus, TimestampMs}; @@ -348,11 +349,6 @@ async fn send_to_agent_persists_assistant_snapshot_for_channel_bound_assistant() let snapshot = snapshot.unwrap(); let conversation = conversation_repo.get(&result.conversation_id).await.unwrap().unwrap(); assert_eq!(conversation.r#type, AgentType::Acp.serde_name()); - let extra: serde_json::Value = serde_json::from_str(&conversation.extra).unwrap(); - assert!( - extra.get("backend").is_none(), - "assistant-bound channel conversations should not persist legacy extra.backend" - ); let session_row = acp_session_repo .get(&result.conversation_id) .await @@ -365,6 +361,65 @@ async fn send_to_agent_persists_assistant_snapshot_for_channel_bound_assistant() assert_eq!(conversation.name, "Claude"); } +#[tokio::test] +async fn send_to_agent_rejects_unresolvable_channel_assistant_binding() { + let db = init_database_memory().await.unwrap(); + let pool = db.pool().clone(); + + let task_manager: Arc = Arc::new(RecordingTaskManager::new()); + let conversation_repo = Arc::new(SqliteConversationRepository::new(pool.clone())); + let conversation_repo_trait: Arc = conversation_repo.clone(); + let acp_session_repo = Arc::new(SqliteAcpSessionRepository::new(pool.clone())); + let conversation_svc = Arc::new(ConversationService::new( + std::env::temp_dir(), + Arc::new(TestBroadcaster::new()), + Arc::new(NoopSkillResolver), + Arc::clone(&task_manager), + conversation_repo_trait, + Arc::new(SqliteAgentMetadataRepository::new(pool.clone())), + acp_session_repo, + )); + + let pref_repo = Arc::new(SqliteClientPreferenceRepository::new(pool.clone())); + pref_repo + .upsert_batch(&[( + "assistant.telegram.agent", + r#"{"assistant_id":"missing-assistant","name":"Missing"}"#, + )]) + .await + .unwrap(); + let definition_repo = Arc::new(SqliteAssistantDefinitionRepository::new(pool.clone())); + let overlay_repo = Arc::new(SqliteAssistantOverlayRepository::new(pool.clone())); + let settings = Arc::new(ChannelSettingsService::new(pref_repo).with_assistant_repos(definition_repo, overlay_repo)); + let message_svc = ChannelMessageService::new( + conversation_svc, + Arc::clone(&task_manager), + settings, + "system_default_user".to_owned(), + ); + + let session = AssistantSessionRow { + id: "session-assisted-missing".to_owned(), + user_id: "channel-user-missing".to_owned(), + agent_type: "aionrs".to_owned(), + conversation_id: None, + workspace: None, + chat_id: Some("7088048017".to_owned()), + created_at: 1, + last_activity: 1, + }; + + let err = message_svc + .send_to_agent(&session, "hello", PluginType::Telegram) + .await + .unwrap_err(); + assert!(matches!(err, ChannelError::MessageSendFailed(_))); + assert!( + err.to_string().contains("missing-assistant"), + "error should surface the unresolved assistant identity" + ); +} + #[tokio::test] async fn send_to_agent_without_assistant_name_falls_back_to_legacy_channel_name() { let db = init_database_memory().await.unwrap(); From 79e95ecd505f315553287b7277544d06a5bcde34 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 15:54:23 +0800 Subject: [PATCH 095/135] feat(agent): add command/env override columns to agent_metadata --- .../aionui-db/migrations/013_agent_connection_snapshot.sql | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/aionui-db/migrations/013_agent_connection_snapshot.sql b/crates/aionui-db/migrations/013_agent_connection_snapshot.sql index a77f53690..e484eb93b 100644 --- a/crates/aionui-db/migrations/013_agent_connection_snapshot.sql +++ b/crates/aionui-db/migrations/013_agent_connection_snapshot.sql @@ -12,3 +12,9 @@ ALTER TABLE agent_metadata ADD COLUMN last_check_latency_ms INTEGER; ALTER TABLE agent_metadata ADD COLUMN last_check_at INTEGER; ALTER TABLE agent_metadata ADD COLUMN last_success_at INTEGER; ALTER TABLE agent_metadata ADD COLUMN last_failure_at INTEGER; + +-- Self-repair overrides: user-supplied executable path and extra env vars, +-- layered on top of the seed row at projection time. Stored plaintext, same +-- as the existing `env` column. +ALTER TABLE agent_metadata ADD COLUMN command_override TEXT; +ALTER TABLE agent_metadata ADD COLUMN env_override TEXT; From d037fb194dc70f274587e96810f0d2e0bcfe4e39 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 15:58:22 +0800 Subject: [PATCH 096/135] feat(agent): repo read/write for command and env overrides --- crates/aionui-db/src/models/agent_metadata.rs | 3 ++ .../src/repository/agent_metadata.rs | 11 ++++++ .../src/repository/sqlite_agent_metadata.rs | 38 +++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/crates/aionui-db/src/models/agent_metadata.rs b/crates/aionui-db/src/models/agent_metadata.rs index 16cec63a8..ec1b19653 100644 --- a/crates/aionui-db/src/models/agent_metadata.rs +++ b/crates/aionui-db/src/models/agent_metadata.rs @@ -58,6 +58,9 @@ pub struct AgentMetadataRow { pub last_success_at: Option, pub last_failure_at: Option, + pub command_override: Option, + pub env_override: Option, + pub created_at: TimestampMs, pub updated_at: TimestampMs, } diff --git a/crates/aionui-db/src/repository/agent_metadata.rs b/crates/aionui-db/src/repository/agent_metadata.rs index 9d4672f23..33df906ad 100644 --- a/crates/aionui-db/src/repository/agent_metadata.rs +++ b/crates/aionui-db/src/repository/agent_metadata.rs @@ -51,6 +51,17 @@ pub trait IAgentMetadataRepository: Send + Sync { params: &UpdateAgentAvailabilitySnapshotParams<'_>, ) -> Result, DbError>; + /// Write only the self-repair override columns for an agent, leaving all + /// other columns (seed truth + availability snapshot) untouched. Kept + /// separate from the full-row upsert so startup reconcile never clobbers + /// user overrides. + async fn update_agent_overrides( + &self, + id: &str, + command_override: Option<&str>, + env_override: Option<&str>, + ) -> Result<(), DbError>; + /// Toggle the `enabled` flag. Returns `true` if a row was updated. async fn set_enabled(&self, id: &str, enabled: bool) -> Result; diff --git a/crates/aionui-db/src/repository/sqlite_agent_metadata.rs b/crates/aionui-db/src/repository/sqlite_agent_metadata.rs index 04135310b..1bdcdaeec 100644 --- a/crates/aionui-db/src/repository/sqlite_agent_metadata.rs +++ b/crates/aionui-db/src/repository/sqlite_agent_metadata.rs @@ -234,6 +234,26 @@ impl IAgentMetadataRepository for SqliteAgentMetadataRepository { self.get(id).await } + async fn update_agent_overrides( + &self, + id: &str, + command_override: Option<&str>, + env_override: Option<&str>, + ) -> Result<(), DbError> { + sqlx::query( + "UPDATE agent_metadata SET command_override = ?, env_override = ?, \ + updated_at = ? WHERE id = ?", + ) + .bind(command_override) + .bind(env_override) + .bind(aionui_common::now_ms()) + .bind(id) + .execute(&self.pool) + .await + .map_err(DbError::Query)?; + Ok(()) + } + async fn set_enabled(&self, id: &str, enabled: bool) -> Result { let now = now_ms(); let result = sqlx::query("UPDATE agent_metadata SET enabled = ?, updated_at = ? WHERE id = ?") @@ -558,4 +578,22 @@ mod tests { "both rows should coexist after dropping UNIQUE(agent_source,name)" ); } + + #[tokio::test] + async fn update_agent_overrides_persists_and_leaves_other_columns() { + let (repo, _db) = setup().await; + // Seed one agent row + let p = custom_params("agent-x", "agent-x"); + repo.upsert(&p).await.unwrap(); + + repo.update_agent_overrides("agent-x", Some("/real/bin/x"), Some(r#"[{"name":"K","value":"V"}]"#)) + .await + .unwrap(); + + let row = repo.get("agent-x").await.unwrap().unwrap(); + assert_eq!(row.command_override.as_deref(), Some("/real/bin/x")); + assert_eq!(row.env_override.as_deref(), Some(r#"[{"name":"K","value":"V"}]"#)); + // seed columns untouched + assert_eq!(row.name, "agent-x"); + } } From 3a15f1f0c4a5f41a238a2716c08aefb38495a094 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 16:01:27 +0800 Subject: [PATCH 097/135] fix(conversation): prefer assistant runtime seeds on create --- crates/aionui-conversation/src/service.rs | 10 +- .../aionui-conversation/src/service_test.rs | 99 +++++++++++++++++++ 2 files changed, 102 insertions(+), 7 deletions(-) diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 92b7167be..9844dc04a 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -781,19 +781,15 @@ impl ConversationService { } else { obj.remove("agent_source"); } - if let Some(model_id) = snapshot.resolved_defaults.model.as_ref() - && !obj.contains_key("current_model_id") - { + if let Some(model_id) = snapshot.resolved_defaults.model.as_ref() { obj.insert( "current_model_id".to_owned(), serde_json::Value::String(model_id.clone()), ); } if let Some(permission) = snapshot.resolved_defaults.permission.as_ref() { - if !obj.contains_key("session_mode") { - obj.insert("session_mode".to_owned(), serde_json::Value::String(permission.clone())); - } - if matches!(effective_type, AgentType::Acp) && !obj.contains_key("current_mode_id") { + obj.insert("session_mode".to_owned(), serde_json::Value::String(permission.clone())); + if matches!(effective_type, AgentType::Acp) { obj.insert( "current_mode_id".to_owned(), serde_json::Value::String(permission.clone()), diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 62a85e656..19ce0b75c 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -4312,6 +4312,105 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { ); } +#[tokio::test] +async fn create_prefers_assistant_snapshot_over_legacy_runtime_seed_fields() { + let resolver = Arc::new(FixedSkillResolver { + names: vec!["cron".into(), "todo-tracker".into()], + }); + let dispatcher = Arc::new(StaticAssistantDispatcher { + rules: std::collections::HashMap::from([("preset-1".to_string(), "assistant rule body".to_string())]), + }); + let (svc, _broadcaster, repo, definition_repo, state_repo, preference_repo) = + make_service_with_assistant_support(resolver, dispatcher).await; + let workspace = ensure_test_workspace_path(); + + definition_repo + .upsert(&UpsertAssistantDefinitionParams { + definition_id: "asstdef_preset_legacy_seed", + assistant_key: "preset-1", + source: "builtin", + owner_type: "system", + source_ref: Some("preset-1"), + source_version: None, + source_hash: None, + name: "Preset", + name_i18n: "{}", + description: Some("desc"), + description_i18n: "{}", + avatar_type: "emoji", + avatar_value: Some("🤖"), + agent_backend: "claude", + rule_resource_type: "builtin_asset", + rule_resource_ref: Some("preset-1"), + rule_inline_content: None, + recommended_prompts: "[]", + recommended_prompts_i18n: "{}", + default_model_mode: "auto", + default_model_value: None, + default_permission_mode: "auto", + default_permission_value: None, + default_skills_mode: "auto", + default_skill_ids: "[]", + custom_skill_names: "[]", + default_disabled_builtin_skill_ids: "[]", + default_mcps_mode: "auto", + default_mcp_ids: "[]", + }) + .await + .unwrap(); + state_repo + .upsert(&UpsertAssistantOverlayParams { + definition_id: "asstdef_preset_legacy_seed", + enabled: true, + sort_order: 0, + agent_backend_override: Some("codex"), + last_used_at: None, + }) + .await + .unwrap(); + preference_repo + .upsert(&UpsertAssistantPreferenceParams { + definition_id: "asstdef_preset_legacy_seed", + last_model_id: Some("preferred-model"), + last_permission_value: Some("workspace-write"), + last_skill_ids: r#"["legacy-skill"]"#, + last_disabled_builtin_skill_ids: r#"["legacy-disabled"]"#, + last_mcp_ids: r#"["legacy-mcp"]"#, + }) + .await + .unwrap(); + + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "name": "t", + "assistant": { + "id": "preset-1", + "locale": "zh-CN", + "conversation_overrides": { + "model": "override-model" + } + }, + "extra": { + "workspace": workspace, + "backend": "claude", + "current_model_id": "legacy-model", + "session_mode": "legacy-mode", + "current_mode_id": "legacy-mode" + }, + })) + .unwrap(); + let resp = svc.create("user-1", req).await.unwrap(); + + assert_eq!(resp.extra["current_model_id"], json!("override-model")); + assert_eq!(resp.extra["session_mode"], json!("workspace-write")); + assert_eq!(resp.extra["current_mode_id"], json!("workspace-write")); + + let snapshot = repo.get_assistant_snapshot(&resp.id).await.unwrap().unwrap(); + assert_eq!(snapshot.agent_backend, "codex"); + assert_eq!(snapshot.resolved_model_id.as_deref(), Some("override-model")); + assert_eq!(snapshot.resolved_permission_value.as_deref(), Some("workspace-write")); +} + #[tokio::test] async fn create_prefers_snapshot_runtime_identity_over_legacy_extra_identity() { let resolver = Arc::new(FixedSkillResolver { names: vec![] }); From f5d049e0e8530f1435c96efa810a521463eca402 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 16:03:46 +0800 Subject: [PATCH 098/135] feat(agent): add env override key blocklist --- crates/aionui-ai-agent/src/registry.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/aionui-ai-agent/src/registry.rs b/crates/aionui-ai-agent/src/registry.rs index e3e221c0f..1921e1b56 100644 --- a/crates/aionui-ai-agent/src/registry.rs +++ b/crates/aionui-ai-agent/src/registry.rs @@ -646,6 +646,19 @@ fn guidance_for_unavailable_reason(reason: &UnavailableReason) -> String { } } +/// Keys a user-supplied env override must never set — they would corrupt the +/// agent's runtime environment or AionUi-internal wiring. Case-insensitive. +pub(crate) fn is_blocked_override_env_key(key: &str) -> bool { + let upper = key.to_ascii_uppercase(); + if upper.starts_with("AIONUI_") { + return true; + } + matches!( + upper.as_str(), + "HOME" | "PATH" | "USER" | "SHELL" | "TERM" | "CODEX_HOME" + ) +} + pub(crate) fn guidance_for_snapshot_error_code(error_code: &str) -> &'static str { match error_code { "command_not_found" => { @@ -1104,4 +1117,14 @@ mod tests { Some(serde_json::json!({"x": 1})) ); } + + #[test] + fn blocked_override_env_keys() { + for k in ["HOME", "PATH", "USER", "SHELL", "TERM", "CODEX_HOME", "AIONUI_FOO", "aionui_bar", "path"] { + assert!(super::is_blocked_override_env_key(k), "{k} should be blocked"); + } + for k in ["ANTHROPIC_API_KEY", "FACTORY_API_KEY", "MY_VAR"] { + assert!(!super::is_blocked_override_env_key(k), "{k} should be allowed"); + } + } } From 92cc22d2a715400aaec8ff436d6b540c283dd522 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 16:07:43 +0800 Subject: [PATCH 099/135] feat(agent): merge command/env overrides at row projection --- crates/aionui-ai-agent/src/registry.rs | 135 +++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/crates/aionui-ai-agent/src/registry.rs b/crates/aionui-ai-agent/src/registry.rs index 1921e1b56..e04ff2ee1 100644 --- a/crates/aionui-ai-agent/src/registry.rs +++ b/crates/aionui-ai-agent/src/registry.rs @@ -366,12 +366,33 @@ fn is_visible(meta: &AgentMetadata) -> bool { meta.enabled && matches!(derive_management_status(meta), AgentManagementStatus::Available) } +/// Extract and trim a command override, filtering out empty strings. +fn meta_command_override(raw: &Option) -> Option { + raw.as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) +} + +/// Parse env_override JSON string into a vector of AgentEnvEntry. +fn parse_env_override(raw: &Option) -> Option> { + let s = raw.as_deref()?.trim(); + if s.is_empty() { + return None; + } + serde_json::from_str::>(s).ok() +} + /// Turn a DB row into the public `AgentMetadata`, probing the command /// on disk so `available` reflects the current PATH state. Returns /// the probe reason alongside the row so the caller can log a single /// uniform `(meta, reason)` line per agent without re-running the /// probe. fn decode_row(row: AgentMetadataRow) -> Option<(AgentMetadata, Option)> { + // Extract override fields before row is partially moved + let command_override_raw = row.command_override.clone(); + let env_override_raw = row.env_override.clone(); + let agent_type = parse_agent_type(&row.agent_type)?; let agent_source = parse_agent_source(&row.agent_source)?; let agent_source_info = decode_json_field(row.agent_source_info.as_deref(), "agent_source_info") @@ -429,6 +450,23 @@ fn decode_row(row: AgentMetadataRow) -> Option<(AgentMetadata, Option = meta.env.iter().map(|e| e.name.as_str()).collect(); + assert!(names.contains(&"BASE")); + assert!(names.contains(&"ANTHROPIC_API_KEY")); + assert!(!names.contains(&"PATH"), "blocked key must be skipped"); + } } From 80def580a60c06fd6dffc861baac701f3c7a35fc Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 16:15:58 +0800 Subject: [PATCH 100/135] feat(agent): expose override summary fields on management row --- crates/aionui-ai-agent/src/factory/acp.rs | 2 ++ .../src/manager/acp/codex_sandbox.rs | 2 ++ .../src/manager/acp/mode_normalize.rs | 2 ++ crates/aionui-ai-agent/src/protocol/cli_detect.rs | 2 ++ crates/aionui-ai-agent/src/registry.rs | 9 +++++++++ crates/aionui-ai-agent/src/registry_tests.rs | 6 ++++++ .../src/services/availability/mod.rs | 2 ++ crates/aionui-api-types/src/agent_discovery.rs | 15 +++++++++++++++ crates/aionui-assistant/src/service.rs | 2 ++ 9 files changed, 42 insertions(+) diff --git a/crates/aionui-ai-agent/src/factory/acp.rs b/crates/aionui-ai-agent/src/factory/acp.rs index f74a4b84c..99462834b 100644 --- a/crates/aionui-ai-agent/src/factory/acp.rs +++ b/crates/aionui-ai-agent/src/factory/acp.rs @@ -687,6 +687,8 @@ mod tests { last_success_at: None, last_failure_at: None, handshake: aionui_api_types::AgentHandshake::default(), + has_command_override: false, + env_override_key_count: 0, }; let spec = resolve_agent_command_spec( diff --git a/crates/aionui-ai-agent/src/manager/acp/codex_sandbox.rs b/crates/aionui-ai-agent/src/manager/acp/codex_sandbox.rs index 068010acb..fd1bb8af7 100644 --- a/crates/aionui-ai-agent/src/manager/acp/codex_sandbox.rs +++ b/crates/aionui-ai-agent/src/manager/acp/codex_sandbox.rs @@ -246,6 +246,8 @@ mod tests { last_success_at: None, last_failure_at: None, handshake: aionui_api_types::AgentHandshake::default(), + has_command_override: false, + env_override_key_count: 0, } } diff --git a/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs b/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs index a62b3c538..4a46ef1df 100644 --- a/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs +++ b/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs @@ -81,6 +81,8 @@ mod tests { last_success_at: None, last_failure_at: None, handshake: AgentHandshake::default(), + has_command_override: false, + env_override_key_count: 0, } } diff --git a/crates/aionui-ai-agent/src/protocol/cli_detect.rs b/crates/aionui-ai-agent/src/protocol/cli_detect.rs index c3426be3c..449ad2d90 100644 --- a/crates/aionui-ai-agent/src/protocol/cli_detect.rs +++ b/crates/aionui-ai-agent/src/protocol/cli_detect.rs @@ -90,6 +90,8 @@ mod tests { last_success_at: None, last_failure_at: None, handshake: AgentHandshake::default(), + has_command_override: false, + env_override_key_count: 0, } } diff --git a/crates/aionui-ai-agent/src/registry.rs b/crates/aionui-ai-agent/src/registry.rs index e04ff2ee1..a4622537a 100644 --- a/crates/aionui-ai-agent/src/registry.rs +++ b/crates/aionui-ai-agent/src/registry.rs @@ -311,6 +311,8 @@ impl AgentRegistry { last_check_at: meta.last_check_at, last_success_at: meta.last_success_at, last_failure_at: meta.last_failure_at, + has_command_override: meta.has_command_override, + env_override_key_count: meta.env_override_key_count, } }) .collect(); @@ -448,12 +450,19 @@ fn decode_row(row: AgentMetadataRow) -> Option<(AgentMetadata, Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_failure_at: Option, + #[serde(default)] + pub has_command_override: bool, + #[serde(default)] + pub env_override_key_count: usize, } #[cfg(test)] @@ -344,6 +357,8 @@ mod tests { last_success_at: None, last_failure_at: None, handshake: AgentHandshake::default(), + has_command_override: false, + env_override_key_count: 0, }; let v = serde_json::to_value(&meta).unwrap(); assert_eq!(v["id"], "abc12345"); diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 2ef4c0c65..5746c1181 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -2428,6 +2428,8 @@ mod tests { last_check_at: Some(1_750_000_000_000), last_success_at: Some(1_750_000_000_000), last_failure_at: None, + has_command_override: false, + env_override_key_count: 0, } } From f8b10bb145b8918f102f3c9af141f23a67920024 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 16:24:45 +0800 Subject: [PATCH 101/135] feat(agent): add overrides endpoint and service Implement PUT/GET /api/agents/{id}/overrides endpoints for managing agent command and environment variable overrides. The service stores plaintext JSON in the database and filters blocked keys (PATH, etc.) from management summary counts while preserving full plaintext on GET requests. - Add DTOs: SetAgentOverridesRequest, AgentOverridesResponse - Add service methods: set_agent_overrides, get_agent_overrides - Add handlers and routes for PUT/GET /api/agents/{id}/overrides - Make management_row_by_id public for service access - Add e2e test verifying roundtrip and management summary safety --- crates/aionui-ai-agent/src/routes/agent.rs | 40 +++++++++++- crates/aionui-ai-agent/src/services/agent.rs | 61 +++++++++++++++++++ .../src/services/availability/mod.rs | 2 +- crates/aionui-api-types/src/custom_agent.rs | 17 ++++++ crates/aionui-api-types/src/lib.rs | 3 +- .../aionui-app/tests/agent_integration_e2e.rs | 42 +++++++++++++ 6 files changed, 160 insertions(+), 5 deletions(-) diff --git a/crates/aionui-ai-agent/src/routes/agent.rs b/crates/aionui-ai-agent/src/routes/agent.rs index 677c67141..98db9537a 100644 --- a/crates/aionui-ai-agent/src/routes/agent.rs +++ b/crates/aionui-ai-agent/src/routes/agent.rs @@ -14,9 +14,9 @@ use axum::extract::{Extension, Json, Path, State}; use axum::routing::{get, patch, post, put}; use aionui_api_types::{ - AgentLogoEntry, AgentManagementRow, AgentMetadata, ApiResponse, CustomAgentUpsertRequest, - DeleteCustomAgentResponse, ProviderHealthCheckRequest, ProviderHealthCheckResponse, SetEnabledRequest, - TryConnectCustomAgentRequest, TryConnectCustomAgentResponse, + AgentLogoEntry, AgentManagementRow, AgentMetadata, AgentOverridesResponse, ApiResponse, CustomAgentUpsertRequest, + DeleteCustomAgentResponse, ProviderHealthCheckRequest, ProviderHealthCheckResponse, SetAgentOverridesRequest, + SetEnabledRequest, TryConnectCustomAgentRequest, TryConnectCustomAgentResponse, }; use aionui_auth::CurrentUser; use aionui_common::ApiError; @@ -32,6 +32,10 @@ pub fn agent_routes(state: AgentRouterState) -> Router { .route("/api/agents/{id}/health-check", post(health_check_by_id)) .route("/api/agents/provider-health-check", post(provider_health_check)) .route("/api/agents/{id}/enabled", patch(set_agent_enabled)) + .route( + "/api/agents/{id}/overrides", + get(get_agent_overrides).put(set_agent_overrides), + ) .route("/api/agents/custom", post(create_custom)) .route("/api/agents/custom/{id}", put(update_custom).delete(delete_custom)) .route("/api/agents/custom/try-connect", post(try_connect_custom)) @@ -176,3 +180,33 @@ async fn set_agent_enabled( .map_err(agent_error_to_api_error)?, ))) } + +async fn get_agent_overrides( + State(state): State, + Extension(_user): Extension, + Path(id): Path, +) -> Result>, ApiError> { + Ok(Json(ApiResponse::ok( + state + .service + .get_agent_overrides(&id) + .await + .map_err(agent_error_to_api_error)?, + ))) +} + +async fn set_agent_overrides( + State(state): State, + Extension(_user): Extension, + Path(id): Path, + body: Result, JsonRejection>, +) -> Result>, ApiError> { + let Json(req) = body.map_err(ApiError::from)?; + Ok(Json(ApiResponse::ok( + state + .service + .set_agent_overrides(&id, req) + .await + .map_err(agent_error_to_api_error)?, + ))) +} diff --git a/crates/aionui-ai-agent/src/services/agent.rs b/crates/aionui-ai-agent/src/services/agent.rs index a1fca8add..9d12a64e8 100644 --- a/crates/aionui-ai-agent/src/services/agent.rs +++ b/crates/aionui-ai-agent/src/services/agent.rs @@ -137,4 +137,65 @@ impl AgentService { ) -> Result { self.provider_health.health_check(req).await } + + pub async fn set_agent_overrides( + &self, + id: &str, + req: aionui_api_types::SetAgentOverridesRequest, + ) -> Result { + let repo = self.registry.repo_handle(); + repo.get(id) + .await + .map_err(|e| AgentError::internal(format!("repo.get: {e}")))? + .ok_or_else(|| AgentError::not_found(format!("Agent '{id}' not found")))?; + + let command_override = req + .command_override + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned); + + let env_json = match req.env_override { + Some(entries) if !entries.is_empty() => Some( + serde_json::to_string(&entries) + .map_err(|e| AgentError::internal(format!("encode env_override: {e}")))?, + ), + _ => None, + }; + + repo.update_agent_overrides(id, command_override.as_deref(), env_json.as_deref()) + .await + .map_err(|e| AgentError::internal(format!("repo.update_agent_overrides: {e}")))?; + self.registry.invalidate_and_rehydrate().await?; + + self.availability + .management_row_by_id(id) + .await + .ok_or_else(|| AgentError::not_found(format!("Agent '{id}' not found"))) + } + + pub async fn get_agent_overrides( + &self, + id: &str, + ) -> Result { + let row = self + .registry + .repo_handle() + .get(id) + .await + .map_err(|e| AgentError::internal(format!("repo.get: {e}")))? + .ok_or_else(|| AgentError::not_found(format!("Agent '{id}' not found")))?; + + let env_override = row + .env_override + .as_deref() + .and_then(|s| serde_json::from_str::>(s).ok()) + .unwrap_or_default(); + + Ok(aionui_api_types::AgentOverridesResponse { + command_override: row.command_override, + env_override, + }) + } } diff --git a/crates/aionui-ai-agent/src/services/availability/mod.rs b/crates/aionui-ai-agent/src/services/availability/mod.rs index 1ef5c8e3b..da965df7b 100644 --- a/crates/aionui-ai-agent/src/services/availability/mod.rs +++ b/crates/aionui-ai-agent/src/services/availability/mod.rs @@ -120,7 +120,7 @@ impl AgentAvailabilityService { self.persist_snapshot(agent_id, &snapshot).await } - async fn management_row_by_id(&self, id: &str) -> Option { + pub async fn management_row_by_id(&self, id: &str) -> Option { self.registry .list_management_rows() .await diff --git a/crates/aionui-api-types/src/custom_agent.rs b/crates/aionui-api-types/src/custom_agent.rs index 0a3f88d50..4732cac43 100644 --- a/crates/aionui-api-types/src/custom_agent.rs +++ b/crates/aionui-api-types/src/custom_agent.rs @@ -10,6 +10,23 @@ use serde::{Deserialize, Serialize}; use crate::agent_discovery::{AgentEnvEntry, BehaviorPolicy}; +/// Request body for `PUT /api/agents/{id}/overrides`. +#[derive(Debug, Clone, Deserialize)] +pub struct SetAgentOverridesRequest { + #[serde(default)] + pub command_override: Option, + #[serde(default)] + pub env_override: Option>, +} + +/// Response body for `GET /api/agents/{id}/overrides`. +#[derive(Debug, Clone, Serialize)] +pub struct AgentOverridesResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub command_override: Option, + pub env_override: Vec, +} + /// Payload shared by `POST /api/agents/custom` and /// `PUT /api/agents/custom/{id}`. /// diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index 0a32e800a..9f2fd14ed 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -88,7 +88,8 @@ pub use cron::{ HasSkillResponse, ListCronJobsQuery, RunNowResponse, SaveCronSkillRequest, UpdateCronJobRequest, }; pub use custom_agent::{ - CustomAgentAdvancedOverrides, CustomAgentUpsertRequest, DeleteCustomAgentResponse, SetEnabledRequest, + AgentOverridesResponse, CustomAgentAdvancedOverrides, CustomAgentUpsertRequest, DeleteCustomAgentResponse, + SetAgentOverridesRequest, SetEnabledRequest, }; pub use extension::{ DisableExtensionRequest, EnableExtensionRequest, ExtensionSummaryResponse, GetI18nRequest, GetPermissionsRequest, diff --git a/crates/aionui-app/tests/agent_integration_e2e.rs b/crates/aionui-app/tests/agent_integration_e2e.rs index 826aaec9d..69b9dd420 100644 --- a/crates/aionui-app/tests/agent_integration_e2e.rs +++ b/crates/aionui-app/tests/agent_integration_e2e.rs @@ -616,3 +616,45 @@ async fn side_question_with_mock_agent() { "Expected 200 or 500, got {status}" ); } + +// ── Agent overrides roundtrip ─────────────────────────────────── + +#[tokio::test] +async fn agent_overrides_roundtrip_and_management_summary() { + let (mut app, services, _mock_tm) = build_app_with_mock_tasks().await; + let (token, csrf) = setup_and_login(&mut app, &services, "admin", "Pass123!").await; + upsert_visible_agent_metadata(&services, "ovr-agent", "acp").await; + services.agent_registry.invalidate_and_rehydrate().await.unwrap(); + + // PUT overrides + let body = json!({ + "command_override": "/real/bin/ovr", + "env_override": [{"name": "ANTHROPIC_API_KEY", "value": "sk-x"}, {"name": "PATH", "value": "/evil"}] + }); + let req = json_with_token("PUT", "/api/agents/ovr-agent/overrides", body, &token, &csrf); + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // management row: safe fields, blocked PATH not counted + let mreq = get_with_token("/api/agents/management", &token); + let mbody = body_json(app.clone().oneshot(mreq).await.unwrap()).await; + let row = mbody["data"] + .as_array() + .unwrap() + .iter() + .find(|r| r["id"] == "ovr-agent") + .expect("row present"); + assert_eq!(row["has_command_override"], true); + assert_eq!(row["env_override_key_count"], 1); // PATH excluded + assert!( + row.get("env_override").is_none(), + "management row must not carry env plaintext" + ); + + // GET overrides: plaintext echo + let greq = get_with_token("/api/agents/ovr-agent/overrides", &token); + let gbody = body_json(app.clone().oneshot(greq).await.unwrap()).await; + assert_eq!(gbody["data"]["command_override"], "/real/bin/ovr"); + let envs = gbody["data"]["env_override"].as_array().unwrap(); + assert!(envs.iter().any(|e| e["name"] == "ANTHROPIC_API_KEY")); +} From 5e10d8e7972369a3cf34b700e2228da642fae277 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 16:36:43 +0800 Subject: [PATCH 102/135] fix(team): accept guide JSON success payloads --- .../aionui-app/src/commands/cmd_team_guide.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/aionui-app/src/commands/cmd_team_guide.rs b/crates/aionui-app/src/commands/cmd_team_guide.rs index ccbb00d73..f9117beb0 100644 --- a/crates/aionui-app/src/commands/cmd_team_guide.rs +++ b/crates/aionui-app/src/commands/cmd_team_guide.rs @@ -546,6 +546,29 @@ mod tests { assert!(!serialized.contains("unexpected raw body")); } + #[tokio::test] + async fn forward_tool_json_object_success_body_is_returned_as_text() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/tool")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "teamId": "team-123", + "status": "team_created", + "next_step": "Use team_spawn_agent with assistant_id from the catalog.", + }))) + .mount(&mock_server) + .await; + + let server = guide_server_for_port(mock_server.address().port()); + let result = server.forward_tool("aion_create_team", &json!({})).await; + + assert_eq!(result.is_error, Some(false)); + let text = first_text(&result); + assert!(text.contains("\"teamId\":\"team-123\"")); + assert!(text.contains("\"status\":\"team_created\"")); + assert!(text.contains("assistant_id")); + } + #[tokio::test] async fn forward_tool_response_read_failure_is_not_overwritten_by_later_connect_failure() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -635,6 +658,7 @@ impl GuideServer { .or_else(|| extract_nested_code(&v, &["error", "data", "errorCode"])), ); } + return tool_success(v.to_string()); } return tool_error( CliBoundaryCode::McpToolResponseUnexpected, From 603980433a138d00c72b1c4c99cbbef0145529df Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 16:38:27 +0800 Subject: [PATCH 103/135] fix(team): accept guide JSON success payloads --- .../aionui-app/src/commands/cmd_team_guide.rs | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/aionui-app/src/commands/cmd_team_guide.rs b/crates/aionui-app/src/commands/cmd_team_guide.rs index f9117beb0..b4a6d09f8 100644 --- a/crates/aionui-app/src/commands/cmd_team_guide.rs +++ b/crates/aionui-app/src/commands/cmd_team_guide.rs @@ -569,6 +569,28 @@ mod tests { assert!(text.contains("assistant_id")); } + #[tokio::test] + async fn forward_tool_json_array_body_returns_unexpected_without_echoing_body() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/tool")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!(["team-secret-456"]))) + .mount(&mock_server) + .await; + + let server = guide_server_for_port(mock_server.address().port()); + let result = server.forward_tool("team_members", &json!({})).await; + + assert_eq!(result.is_error, Some(true)); + assert_eq!(first_text(&result), "unexpected local guide tool response"); + assert_eq!( + result.structured_content.as_ref().unwrap()["code"], + "MCP_TOOL_RESPONSE_UNEXPECTED" + ); + let serialized = serde_json::to_string(&result).unwrap(); + assert!(!serialized.contains("team-secret-456")); + } + #[tokio::test] async fn forward_tool_response_read_failure_is_not_overwritten_by_later_connect_failure() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -658,7 +680,9 @@ impl GuideServer { .or_else(|| extract_nested_code(&v, &["error", "data", "errorCode"])), ); } - return tool_success(v.to_string()); + if matches!(v, serde_json::Value::Object(_)) { + return tool_success(v.to_string()); + } } return tool_error( CliBoundaryCode::McpToolResponseUnexpected, From 2970ff68c6932b150ea58f1e780b206b9871377f Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 16:45:40 +0800 Subject: [PATCH 104/135] feat(agent): distinguish needs_auth from unavailable status Add NeedsAuth variant to AgentManagementStatus and AgentSnapshotCheckStatus. When a session failure reports UserAgentAuthRequired, record it as needs_auth instead of unavailable. Probes never verify auth, so an available probe must not clear a known needs_auth state (only a real session success clears it). Changes: - agent_discovery.rs: Add NeedsAuth enum variants with serde rename - registry.rs: Parse and derive needs_auth status from DB - availability/mod.rs: Distinguish auth failures in record_session_failure, protect needs_auth from probe overwrites in persist_snapshot - turn_orchestrator.rs: Extract AgentErrorCode from send error and pass user_agent_auth_required code when auth is required Tests: record_session_failure_with_auth_required_persists_needs_auth, probe_available_does_not_clear_needs_auth --- crates/aionui-ai-agent/src/registry.rs | 2 + .../src/services/availability/mod.rs | 167 +++++++++++++++++- .../aionui-api-types/src/agent_discovery.rs | 4 + .../src/turn_orchestrator.rs | 8 +- 4 files changed, 176 insertions(+), 5 deletions(-) diff --git a/crates/aionui-ai-agent/src/registry.rs b/crates/aionui-ai-agent/src/registry.rs index a4622537a..984205bf2 100644 --- a/crates/aionui-ai-agent/src/registry.rs +++ b/crates/aionui-ai-agent/src/registry.rs @@ -589,6 +589,7 @@ fn parse_last_check_status(raw: Option<&str>) -> Option Some(AgentSnapshotCheckStatus::Available), "unavailable" => Some(AgentSnapshotCheckStatus::Unavailable), + "needs_auth" => Some(AgentSnapshotCheckStatus::NeedsAuth), _ => { warn!(value, "agent_metadata: unknown last_check_status"); None @@ -616,6 +617,7 @@ fn derive_management_status(meta: &AgentMetadata) -> AgentManagementStatus { match meta.last_check_status { Some(AgentSnapshotCheckStatus::Unavailable) => AgentManagementStatus::Unavailable, + Some(AgentSnapshotCheckStatus::NeedsAuth) => AgentManagementStatus::NeedsAuth, _ => AgentManagementStatus::Available, } } diff --git a/crates/aionui-ai-agent/src/services/availability/mod.rs b/crates/aionui-ai-agent/src/services/availability/mod.rs index da965df7b..4920fe253 100644 --- a/crates/aionui-ai-agent/src/services/availability/mod.rs +++ b/crates/aionui-ai-agent/src/services/availability/mod.rs @@ -109,8 +109,16 @@ impl AgentAvailabilityService { pub async fn record_session_failure(&self, agent_id: &str, code: &str, message: &str) -> Result<(), AgentError> { let checked_at = now_ms(); + // Auth failures mean "installed + handshakes, but not logged in" — a + // distinct, actionable state, not "broken". Everything else stays + // unavailable. + let status = if code == "user_agent_auth_required" { + "needs_auth" + } else { + "unavailable" + }; let snapshot = AvailabilitySnapshot { - status: "unavailable", + status, kind: "session", error_code: Some(code.to_owned()), error_message: Some(message.to_owned()), @@ -150,8 +158,22 @@ impl AgentAvailabilityService { .map_err(|error| AgentError::internal(format!("repo.get: {error}")))? .ok_or_else(|| AgentError::not_found(format!("Agent '{id}' not found")))?; + // A probe only proves the handshake works; it never verifies auth. + // So a probe's `available` must not overwrite a known `needs_auth` + // (which only a real session can clear). Keep needs_auth, refresh ts. + let is_probe = matches!(snapshot.kind, "manual" | "scheduled" | "startup"); + let keep_needs_auth = is_probe + && snapshot.status == "available" + && existing.last_check_status.as_deref() == Some("needs_auth"); + + let effective_status = if keep_needs_auth { + "needs_auth" + } else { + snapshot.status + }; + let params = UpdateAgentAvailabilitySnapshotParams { - last_check_status: Some(snapshot.status), + last_check_status: Some(effective_status), last_check_kind: Some(snapshot.kind), last_check_error_code: snapshot.error_code.as_deref(), last_check_error_message: snapshot.error_message.as_deref(), @@ -161,12 +183,12 @@ impl AgentAvailabilityService { }), last_check_latency_ms: Some(snapshot.latency_ms), last_check_at: Some(snapshot.checked_at), - last_success_at: if snapshot.status == "available" { + last_success_at: if effective_status == "available" { Some(snapshot.checked_at) } else { existing.last_success_at }, - last_failure_at: if snapshot.status == "unavailable" { + last_failure_at: if effective_status == "unavailable" { Some(snapshot.checked_at) } else { existing.last_failure_at @@ -246,6 +268,7 @@ async fn run_probe( let status = match status { AgentSnapshotCheckStatus::Available => "available", AgentSnapshotCheckStatus::Unavailable => "unavailable", + AgentSnapshotCheckStatus::NeedsAuth => "needs_auth", }; AvailabilitySnapshot { @@ -428,6 +451,142 @@ mod tests { assert!(row.last_failure_at.is_some()); } + #[tokio::test] + async fn record_session_failure_with_auth_required_persists_needs_auth() { + let db = init_database_memory().await.unwrap(); + let repo: Arc = Arc::new(SqliteAgentMetadataRepository::new(db.pool().clone())); + + repo.upsert(&UpsertAgentMetadataParams { + id: "auth-agent", + icon: None, + name: "Auth Agent", + name_i18n: None, + description: None, + description_i18n: None, + backend: Some("github"), + agent_type: "acp", + agent_source: "custom", + agent_source_info: Some(r#"{"binary_name":"gh"}"#), + enabled: true, + command: Some("gh"), + args: Some("[]"), + env: Some("[]"), + native_skills_dirs: None, + behavior_policy: None, + yolo_id: None, + agent_capabilities: None, + auth_methods: None, + config_options: None, + available_modes: None, + available_models: None, + available_commands: None, + sort_order: 100, + }) + .await + .unwrap(); + + let registry = AgentRegistry::new(repo); + registry.hydrate().await.unwrap(); + + let service = AgentAvailabilityService::new(registry.clone(), std::env::temp_dir()); + service + .record_session_failure("auth-agent", "user_agent_auth_required", "needs login") + .await + .unwrap(); + + let row = service + .list_management_rows() + .await + .into_iter() + .find(|item| item.id == "auth-agent") + .unwrap(); + + assert_eq!(row.status, AgentManagementStatus::NeedsAuth); + assert_eq!(row.last_check_status, Some(AgentSnapshotCheckStatus::NeedsAuth)); + assert_eq!(row.last_check_kind, Some(AgentSnapshotCheckKind::Session)); + assert_eq!(row.last_check_error_code.as_deref(), Some("user_agent_auth_required")); + assert_eq!(row.last_check_error_message.as_deref(), Some("needs login")); + } + + #[tokio::test] + async fn probe_available_does_not_clear_needs_auth() { + use super::AvailabilitySnapshot; + + let db = init_database_memory().await.unwrap(); + let repo: Arc = Arc::new(SqliteAgentMetadataRepository::new(db.pool().clone())); + + repo.upsert(&UpsertAgentMetadataParams { + id: "auth-agent-2", + icon: None, + name: "Auth Agent 2", + name_i18n: None, + description: None, + description_i18n: None, + backend: Some("github"), + agent_type: "acp", + agent_source: "custom", + agent_source_info: Some(r#"{"binary_name":"gh"}"#), + enabled: true, + command: Some("gh"), + args: Some("[]"), + env: Some("[]"), + native_skills_dirs: None, + behavior_policy: None, + yolo_id: None, + agent_capabilities: None, + auth_methods: None, + config_options: None, + available_modes: None, + available_models: None, + available_commands: None, + sort_order: 100, + }) + .await + .unwrap(); + + let registry = AgentRegistry::new(repo.clone()); + registry.hydrate().await.unwrap(); + + let service = AgentAvailabilityService::new(registry.clone(), std::env::temp_dir()); + + // First, record an auth failure + service + .record_session_failure("auth-agent-2", "user_agent_auth_required", "needs login") + .await + .unwrap(); + + // Verify it's set to needs_auth + let row = service + .list_management_rows() + .await + .into_iter() + .find(|item| item.id == "auth-agent-2") + .unwrap(); + assert_eq!(row.last_check_status, Some(AgentSnapshotCheckStatus::NeedsAuth)); + + // Now simulate a manual probe that succeeds + let probe = AvailabilitySnapshot { + status: "available", + kind: "manual", + error_code: None, + error_message: None, + latency_ms: 1, + checked_at: aionui_common::now_ms(), + }; + service.persist_snapshot("auth-agent-2", &probe).await.unwrap(); + + // Verify that needs_auth is preserved + let row = service + .list_management_rows() + .await + .into_iter() + .find(|item| item.id == "auth-agent-2") + .unwrap(); + assert_eq!(row.last_check_status, Some(AgentSnapshotCheckStatus::NeedsAuth)); + assert_eq!(row.status, AgentManagementStatus::NeedsAuth); + assert_eq!(row.last_check_kind, Some(AgentSnapshotCheckKind::Manual)); + } + #[tokio::test] async fn background_scheduler_persists_scheduled_snapshot() { let db = init_database_memory().await.unwrap(); diff --git a/crates/aionui-api-types/src/agent_discovery.rs b/crates/aionui-api-types/src/agent_discovery.rs index 852f96615..fe5f48e8e 100644 --- a/crates/aionui-api-types/src/agent_discovery.rs +++ b/crates/aionui-api-types/src/agent_discovery.rs @@ -112,6 +112,8 @@ pub enum AgentManagementStatus { Missing, Available, Unavailable, + #[serde(rename = "needs_auth")] + NeedsAuth, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -119,6 +121,8 @@ pub enum AgentManagementStatus { pub enum AgentSnapshotCheckStatus { Available, Unavailable, + #[serde(rename = "needs_auth")] + NeedsAuth, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/aionui-conversation/src/turn_orchestrator.rs b/crates/aionui-conversation/src/turn_orchestrator.rs index 3fd3ab345..2e55d6aff 100644 --- a/crates/aionui-conversation/src/turn_orchestrator.rs +++ b/crates/aionui-conversation/src/turn_orchestrator.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use aionui_ai_agent::types::{BuildTaskOptions, SendMessageData}; use aionui_ai_agent::{AgentSendError, AgentSessionKind, IWorkerTaskManager}; +use aionui_api_types::AgentErrorCode; use aionui_common::{ConversationStatus, ErrorChain, now_ms}; use aionui_db::models::ConversationRow; use tokio::sync::oneshot; @@ -170,10 +171,15 @@ impl ConversationTurnOrchestrator { tokio::spawn(async move { if let Err(e) = send_agent.send_message(current_send).await { let failure_message = availability_failure_message(&e); + let code = if e.code() == Some(AgentErrorCode::UserAgentAuthRequired) { + "user_agent_auth_required" + } else { + "session_send_failed" + }; record_agent_session_failure( &feedback_service, feedback_agent_id.as_deref(), - "session_send_failed", + code, &failure_message, ) .await; From 0fa143cd6c69b2e5c6bd9a81908eb5b30fec0ed2 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 16:51:11 +0800 Subject: [PATCH 105/135] refactor(team): finish assistant-first guide prompts --- crates/aionui-team/src/prompts/lead.rs | 4 ++++ crates/aionui-team/src/prompts/team_guide.rs | 25 +++++++++++++------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/crates/aionui-team/src/prompts/lead.rs b/crates/aionui-team/src/prompts/lead.rs index 7f8d4a203..b07b78a8a 100644 --- a/crates/aionui-team/src/prompts/lead.rs +++ b/crates/aionui-team/src/prompts/lead.rs @@ -323,9 +323,13 @@ mod tests { assert!(got.contains("skills: docx, formatting")); assert!(got.contains("### How to pick an assistant")); assert!(got.contains("Pass the assistant's ID as `assistant_id`")); + assert!(got.contains("runtime backend")); + assert!(got.contains("derived automatically")); assert!(!got.contains("backend: claude")); assert!(!got.contains("Generic Backends")); assert!(!got.contains("custom_agent_id")); + assert!(!got.contains("choose a backend")); + assert!(!got.contains("select a backend")); } #[test] diff --git a/crates/aionui-team/src/prompts/team_guide.rs b/crates/aionui-team/src/prompts/team_guide.rs index cef33f92a..737f047fd 100644 --- a/crates/aionui-team/src/prompts/team_guide.rs +++ b/crates/aionui-team/src/prompts/team_guide.rs @@ -63,18 +63,18 @@ Before team creation: use **only** `aion_create_team` and `aion_list_models`. Af /// Build the Team Guide prompt for a solo agent. /// -/// * `backend` — agent backend key (`"claude"`, `"gemini"`, `"codex"`, …). Empty +/// * `backend` — assistant runtime backend key (`"claude"`, `"gemini"`, `"codex"`, …). Empty /// string falls back to `"claude"`, matching AionUi `opts.backend || 'claude'`. /// * `leader_label` — optional display name for a preset assistant (e.g. /// `"Word Creator"`). When present it renders as `"{label} ({backend})"`, -/// mirroring the `rawLabel ? "${rawLabel} (${agentType})" : agentType` branch -/// in `teamGuidePrompt.ts`. Whitespace-only labels are treated as absent. +/// mirroring AionUi's preset-label formatting branch. Whitespace-only labels +/// are treated as absent. pub fn build_team_guide_prompt(backend: &str, leader_label: Option<&str>) -> String { - let agent_type = if backend.is_empty() { "claude" } else { backend }; + let leader_backend = if backend.is_empty() { "claude" } else { backend }; let raw_label = leader_label.map(str::trim).filter(|s| !s.is_empty()); let leader_cell = match raw_label { - Some(label) => format!("{label} ({agent_type})"), - None => agent_type.to_owned(), + Some(label) => format!("{label} ({leader_backend})"), + None => format!("Current assistant ({leader_backend})"), }; TEAM_GUIDE_PROMPT_TEMPLATE @@ -122,7 +122,7 @@ If case 2 applies, ask at most once whether the user wants to bring in a Team. K 2. Explain in one sentence why the Team setup helps this task.\n\ 3. Present a team configuration table: role name, responsibility, recommended assistant, and recommended model (from aion_list_models results) for each member. Example format:\n \ | Role | Responsibility | Assistant | Model |\n \ -| Leader | Coordinate and review | claude | (default) |\n \ +| Leader | Coordinate and review | Current assistant (claude) | (default) |\n \ | Developer | Implement features | Suitable assistant | (model from list) |\n \ | Tester | Write and run tests | Suitable assistant | (model from list) |\n\ 4. **Output the table as a normal text message and END YOUR TURN.** Do NOT call `aion_create_team` or any other tool (including ask_user) in this turn. Wait for the user to reply in their next message with explicit confirmation (e.g. \"ok\", \"go ahead\", \"确认\") before proceeding.\n\ @@ -147,12 +147,19 @@ Before team creation: use **only** `aion_create_team` and `aion_list_models`. Af #[test] fn team_guide_prompt_empty_backend_falls_back_to_claude() { let prompt = build_team_guide_prompt("", None); - assert!(prompt.contains("| Leader | Coordinate and review | claude | (default) |")); + assert!(prompt.contains("| Leader | Coordinate and review | Current assistant (claude) | (default) |")); } #[test] fn team_guide_prompt_whitespace_label_treated_as_absent() { let prompt = build_team_guide_prompt("codex", Some(" ")); - assert!(prompt.contains("| Leader | Coordinate and review | codex | (default) |")); + assert!(prompt.contains("| Leader | Coordinate and review | Current assistant (codex) | (default) |")); + } + + #[test] + fn team_guide_prompt_without_leader_label_uses_assistant_first_leader_cell() { + let prompt = build_team_guide_prompt("gemini", None); + assert!(prompt.contains("| Leader | Coordinate and review | Current assistant (gemini) | (default) |")); + assert!(!prompt.contains("| Leader | Coordinate and review | gemini | (default) |")); } } From b2edf5809f4b8edbcc18aaf8e14b69397677b50f Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 16:55:05 +0800 Subject: [PATCH 106/135] refactor(team): sync solo guide prompt with assistant-first copy --- .../src/capability/team_guide_prompt.rs | 53 ++++++++++++------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/crates/aionui-ai-agent/src/capability/team_guide_prompt.rs b/crates/aionui-ai-agent/src/capability/team_guide_prompt.rs index 9bc70e579..5c299969b 100644 --- a/crates/aionui-ai-agent/src/capability/team_guide_prompt.rs +++ b/crates/aionui-ai-agent/src/capability/team_guide_prompt.rs @@ -53,15 +53,15 @@ You can create a multi-agent Team for the user. If case 2 applies, ask at most once whether the user wants to bring in a Team. Keep it brief and optional. If the user says no, ignores it, or prefers solo help, continue solo and do not mention Team again. ### How to proceed when Team is requested or approved (STRICT — follow every step, do NOT skip) -1. FIRST call `aion_list_models` to check available models for each agent type you plan to use. +1. FIRST call `aion_list_models` to check available models for each assistant you plan to use. 2. Explain in one sentence why the Team setup helps this task. -3. Present a team configuration table: role name, responsibility, agent type, and recommended model (from aion_list_models results) for each member. Example format: - | Role | Responsibility | Type | Model | +3. Present a team configuration table: role name, responsibility, recommended assistant, and recommended model (from aion_list_models results) for each member. Example format: + | Role | Responsibility | Assistant | Model | | Leader | Coordinate and review | {leader_cell} | (default) | - | Developer | Implement features | {agent_type} | (model from list) | - | Tester | Write and run tests | {agent_type} | (model from list) | + | Developer | Implement features | Suitable assistant | (model from list) | + | Tester | Write and run tests | Suitable assistant | (model from list) | 4. **Output the table as a normal text message and END YOUR TURN.** Do NOT call `aion_create_team` or any other tool (including ask_user) in this turn. Wait for the user to reply in their next message with explicit confirmation (e.g. \"ok\", \"go ahead\", \"确认\") before proceeding. -5. After user confirms → call `aion_create_team`. The summary MUST include both the goal and the confirmed team configuration. (The system automatically sets the correct agent type — you do NOT need to pass agentType.) +5. After user confirms → call `aion_create_team`. The summary MUST include both the goal and the confirmed team configuration. (The system automatically derives the correct backend from a chosen assistant — you do NOT need to pass agentType when using assistant identities.) 6. After `aion_create_team` returns → you ARE now the team Leader. The system navigates to the team page automatically. **Immediately** use `team_spawn_agent` to create each teammate from the confirmed configuration table. Then use `team_send_message` to assign initial tasks to each spawned teammate. Do NOT end your turn until all teammates are spawned and tasked. 7. User declines or wants changes → adjust or proceed solo. Do not mention Team again unless the user asks. @@ -79,14 +79,15 @@ pub(crate) fn is_solo_team_guide_backend(backend: &str) -> bool { /// An empty `backend` falls back to `"claude"`, matching AionUi's /// `opts.backend || 'claude'` and the team-side helper. pub(crate) fn build_solo_team_guide_prompt(backend: &str) -> String { - let agent_type = if backend.is_empty() { "claude" } else { backend }; + let leader_backend = if backend.is_empty() { "claude" } else { backend }; + let leader_cell = format!("Current assistant ({leader_backend})"); + TEAM_GUIDE_PROMPT_TEMPLATE .replace("{solo_default_rule}", SOLO_DEFAULT_RULE) .replace("{explicit_team_request_criteria}", EXPLICIT_TEAM_REQUEST_CRITERIA) .replace("{extreme_complexity_criteria}", EXTREME_COMPLEXITY_CRITERIA) .replace("{stay_solo_criteria}", STAY_SOLO_CRITERIA) - .replace("{leader_cell}", agent_type) - .replace("{agent_type}", agent_type) + .replace("{leader_cell}", &leader_cell) } #[cfg(test)] @@ -120,16 +121,28 @@ mod tests { } #[test] - fn build_solo_team_guide_prompt_renders_leader_row_with_backend() { + fn build_solo_team_guide_prompt_uses_assistant_table_copy() { let prompt = build_solo_team_guide_prompt("gemini"); - assert!(prompt.contains("| Leader | Coordinate and review | gemini | (default) |")); - assert!(prompt.contains("| Developer | Implement features | gemini | (model from list) |")); + assert!(prompt.contains("| Role | Responsibility | Assistant | Model |")); + assert!(prompt.contains( + "| Leader | Coordinate and review | Current assistant (gemini) | (default) |" + )); + assert!(prompt.contains( + "| Developer | Implement features | Suitable assistant | (model from list) |" + )); + assert!(prompt.contains( + "| Tester | Write and run tests | Suitable assistant | (model from list) |" + )); + assert!(!prompt.contains("| Role | Responsibility | Type | Model |")); + assert!(!prompt.contains("| Leader | Coordinate and review | gemini | (default) |")); } #[test] fn build_solo_team_guide_prompt_empty_backend_falls_back_to_claude() { let prompt = build_solo_team_guide_prompt(""); - assert!(prompt.contains("| Leader | Coordinate and review | claude | (default) |")); + assert!(prompt.contains( + "| Leader | Coordinate and review | Current assistant (claude) | (default) |" + )); } #[test] @@ -165,15 +178,15 @@ Handle the task yourself in the current chat by default. Do NOT proactively reco If case 2 applies, ask at most once whether the user wants to bring in a Team. Keep it brief and optional. If the user says no, ignores it, or prefers solo help, continue solo and do not mention Team again.\n\ \n\ ### How to proceed when Team is requested or approved (STRICT — follow every step, do NOT skip)\n\ -1. FIRST call `aion_list_models` to check available models for each agent type you plan to use.\n\ +1. FIRST call `aion_list_models` to check available models for each assistant you plan to use.\n\ 2. Explain in one sentence why the Team setup helps this task.\n\ -3. Present a team configuration table: role name, responsibility, agent type, and recommended model (from aion_list_models results) for each member. Example format:\n \ -| Role | Responsibility | Type | Model |\n \ -| Leader | Coordinate and review | claude | (default) |\n \ -| Developer | Implement features | claude | (model from list) |\n \ -| Tester | Write and run tests | claude | (model from list) |\n\ +3. Present a team configuration table: role name, responsibility, recommended assistant, and recommended model (from aion_list_models results) for each member. Example format:\n \ +| Role | Responsibility | Assistant | Model |\n \ +| Leader | Coordinate and review | Current assistant (claude) | (default) |\n \ +| Developer | Implement features | Suitable assistant | (model from list) |\n \ +| Tester | Write and run tests | Suitable assistant | (model from list) |\n\ 4. **Output the table as a normal text message and END YOUR TURN.** Do NOT call `aion_create_team` or any other tool (including ask_user) in this turn. Wait for the user to reply in their next message with explicit confirmation (e.g. \"ok\", \"go ahead\", \"确认\") before proceeding.\n\ -5. After user confirms → call `aion_create_team`. The summary MUST include both the goal and the confirmed team configuration. (The system automatically sets the correct agent type — you do NOT need to pass agentType.)\n\ +5. After user confirms → call `aion_create_team`. The summary MUST include both the goal and the confirmed team configuration. (The system automatically derives the correct backend from a chosen assistant — you do NOT need to pass agentType when using assistant identities.)\n\ 6. After `aion_create_team` returns → you ARE now the team Leader. The system navigates to the team page automatically. **Immediately** use `team_spawn_agent` to create each teammate from the confirmed configuration table. Then use `team_send_message` to assign initial tasks to each spawned teammate. Do NOT end your turn until all teammates are spawned and tasked.\n\ 7. User declines or wants changes → adjust or proceed solo. Do not mention Team again unless the user asks.\n\ \n\ From e11141b1c82ccd3bc54fb5813bc1dcbd6265064f Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 17:15:11 +0800 Subject: [PATCH 107/135] fix(agent): implement update_agent_overrides in test stubs --- crates/aionui-conversation/src/service_test.rs | 8 ++++++++ crates/aionui-cron/src/executor.rs | 8 ++++++++ crates/aionui-team/src/test_utils.rs | 9 +++++++++ crates/aionui-team/tests/session_service_integration.rs | 8 ++++++++ 4 files changed, 33 insertions(+) diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 62a85e656..7a746b82c 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -607,6 +607,14 @@ impl IAgentMetadataRepository for StubAgentMetadataRepo { ) -> Result, DbError> { Ok(None) } + async fn update_agent_overrides( + &self, + _id: &str, + _command_override: Option<&str>, + _env_override: Option<&str>, + ) -> Result<(), DbError> { + Ok(()) + } async fn set_enabled(&self, _id: &str, _enabled: bool) -> Result { Ok(false) } diff --git a/crates/aionui-cron/src/executor.rs b/crates/aionui-cron/src/executor.rs index 506dfa601..5485b91df 100644 --- a/crates/aionui-cron/src/executor.rs +++ b/crates/aionui-cron/src/executor.rs @@ -3069,6 +3069,14 @@ mod tests { ) -> Result, aionui_db::DbError> { Ok(None) } + async fn update_agent_overrides( + &self, + _id: &str, + _command_override: Option<&str>, + _env_override: Option<&str>, + ) -> Result<(), aionui_db::DbError> { + Ok(()) + } async fn set_enabled(&self, _id: &str, _enabled: bool) -> Result { Ok(false) } diff --git a/crates/aionui-team/src/test_utils.rs b/crates/aionui-team/src/test_utils.rs index 7eba647b4..fb84b4891 100644 --- a/crates/aionui-team/src/test_utils.rs +++ b/crates/aionui-team/src/test_utils.rs @@ -755,6 +755,15 @@ pub(crate) mod workspace_harness { Ok(None) } + async fn update_agent_overrides( + &self, + _id: &str, + _command_override: Option<&str>, + _env_override: Option<&str>, + ) -> Result<(), DbError> { + Ok(()) + } + async fn set_enabled(&self, _id: &str, _enabled: bool) -> Result { Ok(false) } diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index ac488dfe2..cdc2fd09b 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -784,6 +784,14 @@ impl IAgentMetadataRepository for StubAgentMetadataRepo { ) -> Result, DbError> { Ok(None) } + async fn update_agent_overrides( + &self, + _id: &str, + _command_override: Option<&str>, + _env_override: Option<&str>, + ) -> Result<(), DbError> { + Ok(()) + } async fn set_enabled(&self, _id: &str, _enabled: bool) -> Result { Ok(false) } From 48eecf0940d9a2e8f18605af4ea0de9de3367421 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 17:17:07 +0800 Subject: [PATCH 108/135] fix(team): add override fields to AgentMetadataRow test constructors --- crates/aionui-team/src/mcp/tools.rs | 2 ++ crates/aionui-team/tests/session_service_integration.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/aionui-team/src/mcp/tools.rs b/crates/aionui-team/src/mcp/tools.rs index 73a621022..6c222a404 100644 --- a/crates/aionui-team/src/mcp/tools.rs +++ b/crates/aionui-team/src/mcp/tools.rs @@ -874,6 +874,8 @@ mod tests { last_check_at: None, last_success_at: None, last_failure_at: None, + command_override: None, + env_override: None, created_at: 0, updated_at: 0, } diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index cdc2fd09b..12398f7e9 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -1377,6 +1377,8 @@ fn make_agent_metadata_row(id: &str, backend: &str, icon: &str) -> AgentMetadata last_check_at: None, last_success_at: None, last_failure_at: None, + command_override: None, + env_override: None, created_at: 0, updated_at: 0, } From 1c22001055cbb7f6d9bf82514985192080a191c2 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 17:20:17 +0800 Subject: [PATCH 109/135] refactor(channel): default to bare assistant bindings --- crates/aionui-channel/src/channel_settings.rs | 101 +++++++++++++++++- .../tests/message_service_integration.rs | 72 +++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/crates/aionui-channel/src/channel_settings.rs b/crates/aionui-channel/src/channel_settings.rs index f102c6c9a..846175263 100644 --- a/crates/aionui-channel/src/channel_settings.rs +++ b/crates/aionui-channel/src/channel_settings.rs @@ -174,6 +174,10 @@ impl ChannelSettingsService { } } + if assistant.is_none() { + assistant = self.resolve_default_channel_assistant_setting().await?; + } + Ok(ChannelPlatformSettingsResponse { platform: platform.to_string(), assistant, @@ -189,7 +193,7 @@ impl ChannelSettingsService { let prefs = self.pref_repo.get_by_keys(&[&key]).await?; let Some(pref) = prefs.into_iter().next() else { - return Ok(None); + return self.resolve_default_channel_assistant_setting().await; }; let parsed = if let Some(assistant) = parse_channel_assistant_setting(&pref.value) { @@ -312,6 +316,46 @@ impl ChannelSettingsService { Ok(assistant) } } + + async fn resolve_default_channel_assistant_setting( + &self, + ) -> Result, ChannelError> { + let Some(assistant_id) = self.resolve_default_assistant_identity().await? else { + return Ok(None); + }; + + Ok(Some(ChannelAssistantSettingResponse { + assistant_id: Some(assistant_id), + custom_agent_id: None, + backend: None, + agent_type: None, + name: None, + })) + } + + async fn resolve_default_assistant_identity(&self) -> Result, ChannelError> { + let (Some(definition_repo), Some(overlay_repo)) = + (&self.assistant_definition_repo, &self.assistant_overlay_repo) + else { + return Ok(None); + }; + + let definitions = definition_repo.list().await?; + let overlays = overlay_repo.list().await?; + + let generated_aionrs = definitions.iter().find(|definition| { + definition.source == "generated" && effective_assistant_backend(definition, &overlays) == DEFAULT_AGENT_TYPE + }); + if let Some(definition) = generated_aionrs { + return Ok(Some(definition.assistant_key.clone())); + } + + let any_aionrs = definitions + .iter() + .find(|definition| effective_assistant_backend(definition, &overlays) == DEFAULT_AGENT_TYPE); + + Ok(any_aionrs.map(|definition| definition.assistant_key.clone())) + } } fn agent_key(platform: PluginType) -> String { @@ -363,6 +407,18 @@ fn normalize_channel_assistant_setting_for_write( } } +fn effective_assistant_backend( + definition: &aionui_db::models::AssistantDefinitionRow, + overlays: &[aionui_db::models::AssistantOverlayRow], +) -> String { + overlays + .iter() + .find(|overlay| overlay.definition_id == definition.definition_id) + .and_then(|overlay| overlay.agent_backend_override.as_deref()) + .unwrap_or(definition.agent_backend.as_str()) + .to_owned() +} + fn parse_channel_model_setting(value: &str) -> Option { let parsed: serde_json::Value = serde_json::from_str(value).ok()?; let id = parsed["id"].as_str()?.to_owned(); @@ -868,6 +924,27 @@ mod tests { assert_eq!(setting.name.as_deref(), Some("Codex")); } + #[tokio::test] + async fn get_assistant_setting_defaults_to_generated_aionrs_assistant() { + let repo = Arc::new(MockPrefRepo::new()); + let definition_repo = Arc::new(MockAssistantDefinitionRepo { + rows: vec![ + make_definition("bare-claude", "claude"), + make_definition("bare-aionrs", "aionrs"), + ], + }); + let overlay_repo = Arc::new(MockAssistantOverlayRepo { rows: vec![] }); + let svc = ChannelSettingsService::new(repo).with_assistant_repos(definition_repo, overlay_repo); + + let setting = svc.get_assistant_setting(PluginType::Telegram).await.unwrap().unwrap(); + + assert_eq!(setting.assistant_id.as_deref(), Some("bare-aionrs")); + assert!(setting.custom_agent_id.is_none()); + assert!(setting.backend.is_none()); + assert!(setting.agent_type.is_none()); + assert!(setting.name.is_none()); + } + #[tokio::test] async fn get_assistant_setting_preserves_backend_only_legacy_response() { let repo = Arc::new(MockPrefRepo::with_data(vec![( @@ -924,6 +1001,28 @@ mod tests { assert_eq!(assistant.name.as_deref(), Some("Codex")); } + #[tokio::test] + async fn get_platform_settings_defaults_to_generated_aionrs_assistant() { + let repo = Arc::new(MockPrefRepo::new()); + let definition_repo = Arc::new(MockAssistantDefinitionRepo { + rows: vec![ + make_definition("bare-claude", "claude"), + make_definition("bare-aionrs", "aionrs"), + ], + }); + let overlay_repo = Arc::new(MockAssistantOverlayRepo { rows: vec![] }); + let svc = ChannelSettingsService::new(repo).with_assistant_repos(definition_repo, overlay_repo); + + let settings = svc.get_platform_settings(PluginType::Telegram).await.unwrap(); + let assistant = settings.assistant.expect("assistant settings"); + + assert_eq!(assistant.assistant_id.as_deref(), Some("bare-aionrs")); + assert!(assistant.custom_agent_id.is_none()); + assert!(assistant.backend.is_none()); + assert!(assistant.agent_type.is_none()); + assert!(assistant.name.is_none()); + } + #[tokio::test] async fn get_platform_settings_canonicalizes_backend_only_legacy_response_when_assistant_repos_exist() { let repo = Arc::new(MockPrefRepo::with_data(vec![( diff --git a/crates/aionui-channel/tests/message_service_integration.rs b/crates/aionui-channel/tests/message_service_integration.rs index 5004d59af..cdc998195 100644 --- a/crates/aionui-channel/tests/message_service_integration.rs +++ b/crates/aionui-channel/tests/message_service_integration.rs @@ -420,6 +420,78 @@ async fn send_to_agent_rejects_unresolvable_channel_assistant_binding() { ); } +#[tokio::test] +async fn send_to_agent_without_saved_binding_defaults_to_bare_aionrs_assistant() { + let db = init_database_memory().await.unwrap(); + let pool = db.pool().clone(); + + let task_manager: Arc = Arc::new(RecordingTaskManager::new()); + let conversation_repo = Arc::new(SqliteConversationRepository::new(pool.clone())); + let conversation_repo_trait: Arc = conversation_repo.clone(); + let acp_session_repo = Arc::new(SqliteAcpSessionRepository::new(pool.clone())); + let conversation_svc = Arc::new(ConversationService::new( + std::env::temp_dir(), + Arc::new(TestBroadcaster::new()), + Arc::new(NoopSkillResolver), + Arc::clone(&task_manager), + conversation_repo_trait, + Arc::new(SqliteAgentMetadataRepository::new(pool.clone())), + acp_session_repo, + )); + + let pref_repo = Arc::new(SqliteClientPreferenceRepository::new(pool.clone())); + let definition_repo = Arc::new(SqliteAssistantDefinitionRepository::new(pool.clone())); + let overlay_repo = Arc::new(SqliteAssistantOverlayRepository::new(pool.clone())); + let assistant_preference_repo = Arc::new(SqliteAssistantPreferenceRepository::new(pool.clone())); + conversation_svc.with_assistant_definition_repo(definition_repo.clone()); + conversation_svc.with_assistant_state_repo(overlay_repo.clone()); + conversation_svc.with_assistant_preference_repo(assistant_preference_repo); + definition_repo + .upsert(&bare_assistant_definition_params( + "asstdef-channel-aionrs", + "bare-aionrs", + "aionrs", + )) + .await + .unwrap(); + + let settings = Arc::new(ChannelSettingsService::new(pref_repo).with_assistant_repos(definition_repo, overlay_repo)); + let message_svc = ChannelMessageService::new( + conversation_svc, + Arc::clone(&task_manager), + settings, + "system_default_user".to_owned(), + ); + + let session = AssistantSessionRow { + id: "session-assisted-default-aionrs".to_owned(), + user_id: "channel-user-default".to_owned(), + agent_type: "aionrs".to_owned(), + conversation_id: None, + workspace: None, + chat_id: Some("7088048018".to_owned()), + created_at: 1, + last_activity: 1, + }; + + let result = message_svc + .send_to_agent(&session, "hello", PluginType::Telegram) + .await + .unwrap(); + + let snapshot = conversation_repo + .get_assistant_snapshot(&result.conversation_id) + .await + .unwrap() + .expect("channel-created conversation should default to a bare assistant snapshot"); + let conversation = conversation_repo.get(&result.conversation_id).await.unwrap().unwrap(); + + assert_eq!(snapshot.assistant_key, "bare-aionrs"); + assert_eq!(snapshot.agent_backend, "aionrs"); + assert_eq!(conversation.r#type, AgentType::Aionrs.serde_name()); + assert_eq!(conversation.name, "tg-aionrs-70880480"); +} + #[tokio::test] async fn send_to_agent_without_assistant_name_falls_back_to_legacy_channel_name() { let db = init_database_memory().await.unwrap(); From 0260b830a54bcecb87a8b0261101193e6ac5b5b0 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 17:33:02 +0800 Subject: [PATCH 110/135] fix(team): close assistant-first guide handoff --- .../src/capability/team_guide_prompt.rs | 20 +-- .../aionui-app/src/commands/cmd_team_guide.rs | 23 ++++ .../aionui-app/src/commands/cmd_team_stdio.rs | 24 ++++ crates/aionui-team/src/guide/server.rs | 118 ++++++++++++++++-- crates/aionui-team/src/mcp/server.rs | 29 ++++- crates/aionui-team/src/mcp/tools.rs | 41 ++++++ crates/aionui-team/src/prompts/lead.rs | 4 +- crates/aionui-team/src/prompts/mod.rs | 2 +- .../src/prompts/prompt_templates/lead.txt | 27 ++-- crates/aionui-team/src/prompts/team_guide.rs | 4 +- crates/aionui-team/src/service.rs | 14 ++- .../aionui-team/src/service/spawn_support.rs | 1 - crates/aionui-team/src/session.rs | 104 +++++++++++---- crates/aionui-team/tests/e2e_team_flow.rs | 5 +- .../tests/mcp_server_integration.rs | 7 +- .../tests/session_service_integration.rs | 116 ++++++++++++++--- 16 files changed, 452 insertions(+), 87 deletions(-) diff --git a/crates/aionui-ai-agent/src/capability/team_guide_prompt.rs b/crates/aionui-ai-agent/src/capability/team_guide_prompt.rs index 5c299969b..3a84e4f4b 100644 --- a/crates/aionui-ai-agent/src/capability/team_guide_prompt.rs +++ b/crates/aionui-ai-agent/src/capability/team_guide_prompt.rs @@ -62,7 +62,7 @@ If case 2 applies, ask at most once whether the user wants to bring in a Team. K | Tester | Write and run tests | Suitable assistant | (model from list) | 4. **Output the table as a normal text message and END YOUR TURN.** Do NOT call `aion_create_team` or any other tool (including ask_user) in this turn. Wait for the user to reply in their next message with explicit confirmation (e.g. \"ok\", \"go ahead\", \"确认\") before proceeding. 5. After user confirms → call `aion_create_team`. The summary MUST include both the goal and the confirmed team configuration. (The system automatically derives the correct backend from a chosen assistant — you do NOT need to pass agentType when using assistant identities.) -6. After `aion_create_team` returns → you ARE now the team Leader. The system navigates to the team page automatically. **Immediately** use `team_spawn_agent` to create each teammate from the confirmed configuration table. Then use `team_send_message` to assign initial tasks to each spawned teammate. Do NOT end your turn until all teammates are spawned and tasked. +6. After `aion_create_team` returns → you ARE now the team Leader. The system navigates to the team page automatically. First call `team_list_assistants` if you need the real assistant catalog for the confirmed lineup, and only use returned assistant_id values with `team_spawn_agent`. Then use `team_send_message` to assign initial tasks to each spawned teammate. Do NOT end your turn until all teammates are spawned and tasked. 7. User declines or wants changes → adjust or proceed solo. Do not mention Team again unless the user asks. ### Tool constraint @@ -124,15 +124,9 @@ mod tests { fn build_solo_team_guide_prompt_uses_assistant_table_copy() { let prompt = build_solo_team_guide_prompt("gemini"); assert!(prompt.contains("| Role | Responsibility | Assistant | Model |")); - assert!(prompt.contains( - "| Leader | Coordinate and review | Current assistant (gemini) | (default) |" - )); - assert!(prompt.contains( - "| Developer | Implement features | Suitable assistant | (model from list) |" - )); - assert!(prompt.contains( - "| Tester | Write and run tests | Suitable assistant | (model from list) |" - )); + assert!(prompt.contains("| Leader | Coordinate and review | Current assistant (gemini) | (default) |")); + assert!(prompt.contains("| Developer | Implement features | Suitable assistant | (model from list) |")); + assert!(prompt.contains("| Tester | Write and run tests | Suitable assistant | (model from list) |")); assert!(!prompt.contains("| Role | Responsibility | Type | Model |")); assert!(!prompt.contains("| Leader | Coordinate and review | gemini | (default) |")); } @@ -140,9 +134,7 @@ mod tests { #[test] fn build_solo_team_guide_prompt_empty_backend_falls_back_to_claude() { let prompt = build_solo_team_guide_prompt(""); - assert!(prompt.contains( - "| Leader | Coordinate and review | Current assistant (claude) | (default) |" - )); + assert!(prompt.contains("| Leader | Coordinate and review | Current assistant (claude) | (default) |")); } #[test] @@ -187,7 +179,7 @@ If case 2 applies, ask at most once whether the user wants to bring in a Team. K | Tester | Write and run tests | Suitable assistant | (model from list) |\n\ 4. **Output the table as a normal text message and END YOUR TURN.** Do NOT call `aion_create_team` or any other tool (including ask_user) in this turn. Wait for the user to reply in their next message with explicit confirmation (e.g. \"ok\", \"go ahead\", \"确认\") before proceeding.\n\ 5. After user confirms → call `aion_create_team`. The summary MUST include both the goal and the confirmed team configuration. (The system automatically derives the correct backend from a chosen assistant — you do NOT need to pass agentType when using assistant identities.)\n\ -6. After `aion_create_team` returns → you ARE now the team Leader. The system navigates to the team page automatically. **Immediately** use `team_spawn_agent` to create each teammate from the confirmed configuration table. Then use `team_send_message` to assign initial tasks to each spawned teammate. Do NOT end your turn until all teammates are spawned and tasked.\n\ +6. After `aion_create_team` returns → you ARE now the team Leader. The system navigates to the team page automatically. First call `team_list_assistants` if you need the real assistant catalog for the confirmed lineup, and only use returned assistant_id values with `team_spawn_agent`. Then use `team_send_message` to assign initial tasks to each spawned teammate. Do NOT end your turn until all teammates are spawned and tasked.\n\ 7. User declines or wants changes → adjust or proceed solo. Do not mention Team again unless the user asks.\n\ \n\ ### Tool constraint\n\ diff --git a/crates/aionui-app/src/commands/cmd_team_guide.rs b/crates/aionui-app/src/commands/cmd_team_guide.rs index b4a6d09f8..c5533f2ca 100644 --- a/crates/aionui-app/src/commands/cmd_team_guide.rs +++ b/crates/aionui-app/src/commands/cmd_team_guide.rs @@ -364,6 +364,14 @@ impl GuideServer { .await } + #[tool( + name = "team_list_assistants", + description = "List the assistants available for team spawning. Returns the real assistant catalog with real assistant_id values, names, backends, descriptions, and skills.\n\nUse this before team_spawn_agent when you need the exact assistant_id for a teammate. Do NOT guess from backend names like claude/codex/gemini - only use assistant_id values returned here." + )] + async fn team_list_assistants(&self) -> CallToolResult { + self.forward_tool("team_list_assistants", &serde_json::json!({})).await + } + #[tool( name = "team_list_models", description = "Query available models for team assistants." @@ -463,6 +471,21 @@ mod tests { assert!(err.to_string().contains("custom_agent_id")); } + #[test] + fn guide_router_exposes_team_list_assistants() { + let router = GuideServer::tool_router(); + let tools = router.list_all(); + let team_list_assistants = tools + .iter() + .find(|tool| tool.name == "team_list_assistants") + .expect("team_list_assistants tool missing"); + let properties = team_list_assistants.input_schema["properties"].as_object().unwrap(); + assert!( + properties.is_empty(), + "team_list_assistants should not accept arguments" + ); + } + fn guide_server_for_port(port: u16) -> GuideServer { GuideServer { port, diff --git a/crates/aionui-app/src/commands/cmd_team_stdio.rs b/crates/aionui-app/src/commands/cmd_team_stdio.rs index 48f74b994..6909b41c0 100644 --- a/crates/aionui-app/src/commands/cmd_team_stdio.rs +++ b/crates/aionui-app/src/commands/cmd_team_stdio.rs @@ -309,6 +309,15 @@ impl TeamStdioServer { .await } + #[tool( + name = "team_list_assistants", + description = "List the assistants available for team spawning. Returns the real assistant catalog with real assistant_id values, names, backends, descriptions, and skills.\n\nUse this before team_spawn_agent when you need the exact assistant_id for a teammate. Do NOT guess from backend names like claude/codex/gemini - only use assistant_id values returned here." + )] + async fn list_assistants(&self) -> CallToolResult { + self.forward_to_tcp("team_list_assistants", &serde_json::json!({})) + .await + } + #[tool( name = "team_list_models", description = "Query available models for team assistants. Returns the real-time model list that matches the frontend model selector.\n\nUse this to:\n- Check what models are available before spawning an agent with a specific model\n- See all available assistant backends and their models at once\n- Verify a model ID is valid for a given assistant\n\nPass assistant_id to query a specific assistant, or omit it to see all backends." @@ -598,6 +607,21 @@ mod tests { assert!(err.to_string().contains("custom_agent_id")); } + #[test] + fn team_stdio_router_exposes_team_list_assistants() { + let router = TeamStdioServer::tool_router(); + let tools = router.list_all(); + let team_list_assistants = tools + .iter() + .find(|tool| tool.name == "team_list_assistants") + .expect("team_list_assistants tool missing"); + let properties = team_list_assistants.input_schema["properties"].as_object().unwrap(); + assert!( + properties.is_empty(), + "team_list_assistants should not accept arguments" + ); + } + #[tokio::test] async fn forward_to_tcp_reports_read_failure_after_accept_close() { let listener = TcpListener::bind((CONNECT_HOST, 0)).await.unwrap(); diff --git a/crates/aionui-team/src/guide/server.rs b/crates/aionui-team/src/guide/server.rs index 554389c2a..eba1bcc59 100644 --- a/crates/aionui-team/src/guide/server.rs +++ b/crates/aionui-team/src/guide/server.rs @@ -294,14 +294,12 @@ async fn exec_create_team( "name": team.name, "route": route, "status": "team_created", - "next_step": format!( - "You are now the team Leader. Your team tools (team_spawn_agent, team_send_message, etc.) are now active. \ - Immediately proceed to spawn teammates as planned. When calling `team_spawn_agent`, use `assistant_id` \ - from the `Available Assistants for Spawning` catalog. Do not use backend names like `claude/codex` as \ - `assistant_id`; for generic vendor teammates, choose the matching `bare:...` assistant from that catalog. \ - Task summary: {}", - params.summary - ) + "next_step": "You are now the team Leader. Your team tools (team_spawn_agent, team_send_message, etc.) are now active. \ + First call `team_list_assistants` if you need the real catalog for the confirmed lineup. When calling \ + `team_spawn_agent`, use only `assistant_id` values returned by `team_list_assistants` / the `Available \ + Assistants for Spawning` catalog. Do not use backend names like `claude/codex` as `assistant_id`; for \ + generic vendor teammates, choose the matching catalog entry. Treat any backend/model labels from the earlier \ + planning summary as runtime hints only, and map each teammate to a real catalog `assistant_id` before spawning." }) } @@ -826,11 +824,115 @@ mod tests { assert_eq!(leader.assistant_id.as_deref(), Some("assistant-lead")); assert_eq!(leader.backend, "codex"); + assert!(next_step.contains("team_list_assistants")); assert!(next_step.contains("assistant_id")); assert!(next_step.contains("Available Assistants for Spawning")); assert!(next_step.contains("claude/codex")); } + #[tokio::test] + async fn create_team_next_step_does_not_echo_backend_only_teammate_plan() { + let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { + row: AssistantDefinitionRow { + definition_id: "def-guide-summary".into(), + assistant_key: "assistant-lead".into(), + source: "user".into(), + owner_type: "user".into(), + source_ref: None, + source_version: None, + source_hash: None, + name: "Lead Assistant".into(), + name_i18n: "{}".into(), + description: None, + description_i18n: "{}".into(), + avatar_type: "emoji".into(), + avatar_value: Some("🤖".into()), + agent_backend: "claude".into(), + rule_resource_type: "inline".into(), + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]".into(), + recommended_prompts_i18n: "{}".into(), + default_model_mode: "auto".into(), + default_model_value: None, + default_permission_mode: "auto".into(), + default_permission_value: None, + default_skills_mode: "auto".into(), + default_skill_ids: "[]".into(), + custom_skill_names: "[]".into(), + default_disabled_builtin_skill_ids: "[]".into(), + default_mcps_mode: "auto".into(), + default_mcp_ids: "[]".into(), + created_at: 0, + updated_at: 0, + deleted_at: None, + }, + }); + let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { + row: AssistantOverlayRow { + definition_id: "def-guide-summary".into(), + enabled: true, + sort_order: 0, + agent_backend_override: Some("claude".into()), + last_used_at: None, + created_at: 0, + updated_at: 0, + }, + }); + let (svc, _team_repo, _task_manager, conv_repo) = + setup_with_assistants_team_repo_and_conversation_repo(definition_repo, overlay_repo); + + conv_repo + .create(&ConversationRow { + id: "caller-conv-summary".into(), + user_id: "system_default_user".into(), + name: "Caller".into(), + r#type: "acp".into(), + pinned: false, + pinned_at: None, + source: None, + channel_chat_id: None, + extra: serde_json::json!({ + "assistant_id": "assistant-lead", + "workspace": "/tmp/guide-workspace" + }) + .to_string(), + model: None, + status: Some("completed".into()), + created_at: 0, + updated_at: 0, + }) + .await + .expect("seed caller conversation"); + + let server = GuideMcpServer::start().await.expect("start guide server"); + server.set_service(Arc::downgrade(&svc)).await; + + let summary = "已确认的团队配置:\n- 正方辩手:gemini(gemini-3.1-pro-preview)\n- 反方辩手:codex(gpt-5.5)\n- 裁判/评委:claude(global.anthropic.claude-sonnet-4-6)"; + let client = reqwest::Client::new(); + let resp = client + .post(format!("http://127.0.0.1:{}/tool", server.http_port())) + .header("Authorization", format!("Bearer {}", server.auth_token())) + .json(&serde_json::json!({ + "tool": "aion_create_team", + "args": { "summary": summary }, + "conversation_id": "caller-conv-summary", + "user_id": "system_default_user" + })) + .send() + .await + .expect("call guide create team"); + assert_eq!(resp.status(), StatusCode::OK); + + let body: serde_json::Value = resp.json().await.expect("guide create team response"); + let next_step = body["next_step"].as_str().expect("next_step in response"); + + assert!(next_step.contains("team_list_assistants")); + assert!(!next_step.contains("正方辩手:gemini(")); + assert!(!next_step.contains("反方辩手:codex(")); + assert!(!next_step.contains("裁判/评委:claude(")); + } + #[tokio::test] async fn create_team_requires_explicit_assistant_id_for_non_assistant_backed_caller() { let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { diff --git a/crates/aionui-team/src/mcp/server.rs b/crates/aionui-team/src/mcp/server.rs index 8f5abae63..d1bc79a4f 100644 --- a/crates/aionui-team/src/mcp/server.rs +++ b/crates/aionui-team/src/mcp/server.rs @@ -443,12 +443,26 @@ pub(crate) async fn dispatch_tool( "team_shutdown_agent" => { exec_shutdown_agent(arguments, scheduler, service, team_id, caller_slot_id, caller_role).await } + "team_list_assistants" => exec_list_assistants(arguments, service).await, "team_list_models" => exec_list_models(arguments, service).await, "team_describe_assistant" => exec_describe_assistant(arguments, service).await, _ => Err(format!("Unknown tool: {tool_name}")), } } +async fn exec_list_assistants(args: &Value, service: &Weak) -> Result { + let props = args.as_object().cloned().unwrap_or_default(); + if !props.is_empty() { + return Err("team_list_assistants does not accept arguments".to_owned()); + } + let service = service + .upgrade() + .ok_or_else(|| "Team service not available".to_owned())?; + let assistants = service.list_team_selectable_assistants().await; + let value = json!({ "assistants": assistants }); + serde_json::to_string_pretty(&value).map_err(|e| format!("Serialization error: {e}")) +} + async fn exec_list_models(args: &Value, service: &Weak) -> Result { if args.get("backend").is_some() { return Err("backend is no longer accepted; use assistant_id".to_owned()); @@ -572,10 +586,6 @@ async fn exec_send_message( let service = service .upgrade() .ok_or_else(|| "Team service not available; cannot wake target".to_string())?; - service - .require_active_team_run_for_team_work(team_id) - .await - .map_err(|e| e.to_string())?; let targets = if resolved_to == "*" { scheduler @@ -1029,4 +1039,15 @@ mod tests { "expected explicit agent_type rejection, got {err:?}" ); } + + #[tokio::test] + async fn exec_list_assistants_reports_service_unavailable_when_weak_dead() { + let service: Weak = Weak::new(); + let result = exec_list_assistants(&json!({}), &service).await; + let err = result.expect_err("dead service should be surfaced"); + assert!( + err.contains("Team service not available"), + "expected service unavailable error, got {err:?}" + ); + } } diff --git a/crates/aionui-team/src/mcp/tools.rs b/crates/aionui-team/src/mcp/tools.rs index 73a621022..990abfc40 100644 --- a/crates/aionui-team/src/mcp/tools.rs +++ b/crates/aionui-team/src/mcp/tools.rs @@ -53,6 +53,12 @@ relevant from the one-line catalog in your system prompt. Only works on assistants listed in \"Available Assistants for Spawning\". After confirming a match, call team_spawn_agent with the same assistant_id."; +/// Description for `team_list_assistants` — canonical assistant catalog. +pub const TEAM_LIST_ASSISTANTS_DESCRIPTION: &str = "List the assistants available for team spawning. Returns the real assistant catalog with \ +real assistant_id values, names, backends, descriptions, and skills.\n\nUse this before \ +team_spawn_agent when you need the exact assistant_id for a teammate. Do NOT guess from backend \ +names like claude/codex/gemini — only use assistant_id values returned here."; + // --------------------------------------------------------------------------- // Tool descriptors (returned by tools/list) // --------------------------------------------------------------------------- @@ -162,6 +168,14 @@ pub fn all_tool_descriptors() -> Vec { "required": ["slot_id"] }), }, + ToolDescriptor { + name: "team_list_assistants".into(), + description: TEAM_LIST_ASSISTANTS_DESCRIPTION.into(), + input_schema: json!({ + "type": "object", + "properties": {} + }), + }, ToolDescriptor { name: "team_describe_assistant".into(), description: TEAM_DESCRIBE_ASSISTANT_DESCRIPTION.into(), @@ -295,6 +309,7 @@ pub fn parse_tool_call( | "team_members" | "team_rename_agent" | "team_shutdown_agent" + | "team_list_assistants" | "team_list_models" | "team_describe_assistant" => Err("handled directly by server".into()), _ => Err(format!("Unknown tool: {tool_name}")), @@ -695,6 +710,32 @@ mod tests { assert!(!names.contains(&"custom_agent_id")); } + #[test] + fn team_list_assistants_descriptor_guides_real_assistant_ids() { + let desc = all_tool_descriptors() + .into_iter() + .find(|d| d.name == "team_list_assistants") + .expect("team_list_assistants descriptor missing"); + assert!( + desc.description + .starts_with("List the assistants available for team spawning."), + "unexpected descriptor text: {}", + desc.description + ); + assert!(desc.description.contains("real assistant_id values")); + } + + #[test] + fn team_list_assistants_schema_is_empty_object() { + let desc = all_tool_descriptors() + .into_iter() + .find(|d| d.name == "team_list_assistants") + .expect("team_list_assistants descriptor missing"); + let props = desc.input_schema["properties"].as_object().unwrap(); + assert!(props.is_empty(), "team_list_assistants should not accept arguments"); + assert!(desc.input_schema["required"].is_null()); + } + #[test] fn parse_spawn_agent_requires_explicit_assistant_id_field() { let input: SpawnAgentInput = serde_json::from_value(json!({ diff --git a/crates/aionui-team/src/prompts/lead.rs b/crates/aionui-team/src/prompts/lead.rs index b07b78a8a..cfba89b98 100644 --- a/crates/aionui-team/src/prompts/lead.rs +++ b/crates/aionui-team/src/prompts/lead.rs @@ -7,6 +7,8 @@ use std::collections::HashMap; use std::fmt::Write; +use serde::Serialize; + use crate::types::TeamAgent; /// Placeholder for D5b-1's `include_str!("prompt_templates/lead.txt")`. @@ -37,7 +39,7 @@ pub struct AvailableAgentType { /// A preset assistant the leader may spawn via `assistant_id`. /// Phase1 shape per interface-contracts §5 (lines 212-218). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct AvailableAssistant { pub assistant_id: String, pub name: String, diff --git a/crates/aionui-team/src/prompts/mod.rs b/crates/aionui-team/src/prompts/mod.rs index d71b91a38..dd4cfbcd6 100644 --- a/crates/aionui-team/src/prompts/mod.rs +++ b/crates/aionui-team/src/prompts/mod.rs @@ -233,7 +233,7 @@ mod tests { // Workflow — 15-step procedure with model listing at step 3 assert!(prompt.contains("## Workflow")); - assert!(prompt.contains("FIRST call `team_list_models`")); + assert!(prompt.contains("FIRST call `team_list_assistants`")); assert!(prompt.contains("Wait for explicit confirmation before using team_spawn_agent")); assert!(prompt.contains("End your turn after the proposal")); diff --git a/crates/aionui-team/src/prompts/prompt_templates/lead.txt b/crates/aionui-team/src/prompts/prompt_templates/lead.txt index 2a86457d2..60db3ee89 100644 --- a/crates/aionui-team/src/prompts/prompt_templates/lead.txt +++ b/crates/aionui-team/src/prompts/prompt_templates/lead.txt @@ -24,20 +24,21 @@ Use `team_members` and `team_task_list` to check current team state. ## Workflow 1. Receive user request 2. Analyze the request and decide whether the current team is enough -3. If additional teammates would help, FIRST call `team_list_models` to check available models for each assistant you plan to use -4. Then reply in text with a staffing proposal -5. Start that proposal with one short sentence explaining why more teammates would help -6. Present the proposed lineup as a table with: teammate name, responsibility, recommended assistant, and recommended model (from team_list_models results).${presetFormattingStepRule} -7. Ask whether the user wants to create those teammates as proposed or change any names, responsibilities, or assistant choices -8. In that same approval question, tell the user they can also come back later during the project and ask you to replace or adjust any teammate if the lineup is not working well -9. End your turn after the proposal. Do NOT call team_spawn_agent in that same turn +3. If additional teammates would help, FIRST call `team_list_assistants` to see the real assistant catalog and choose candidate assistants +4. Then call `team_list_models` to check available models for each assistant you plan to use +5. Then reply in text with a staffing proposal +6. Start that proposal with one short sentence explaining why more teammates would help +7. Present the proposed lineup as a table with: teammate name, responsibility, recommended assistant, and recommended model (from team_list_models results).${presetFormattingStepRule} +8. Ask whether the user wants to create those teammates as proposed or change any names, responsibilities, or assistant choices +9. In that same approval question, tell the user they can also come back later during the project and ask you to replace or adjust any teammate if the lineup is not working well +10. End your turn after the proposal. Do NOT call team_spawn_agent in that same turn - Exception: If the message contains a [SYSTEM NOTE] indicating the user has already confirmed the lineup, skip the proposal step and proceed directly to spawning all listed teammates -10. Wait for explicit confirmation before using team_spawn_agent, unless the user explicitly told you to create specific teammates immediately or a [SYSTEM NOTE] in the message indicates prior confirmation -11. After the lineup is confirmed, create teammates with team_spawn_agent -12. Break the work into tasks with team_task_create -13. Assign tasks and notify teammates via team_send_message -14. When teammates report back, review results and decide next steps -15. Synthesize results and respond to the user +11. Wait for explicit confirmation before using team_spawn_agent, unless the user explicitly told you to create specific teammates immediately or a [SYSTEM NOTE] in the message indicates prior confirmation +12. After the lineup is confirmed, create teammates with team_spawn_agent +13. Break the work into tasks with team_task_create +14. Assign tasks and notify teammates via team_send_message +15. When teammates report back, review results and decide next steps +16. Synthesize results and respond to the user ## Model Selection Guidelines - Before spawning teammates, use `team_list_models` to check available models for that assistant diff --git a/crates/aionui-team/src/prompts/team_guide.rs b/crates/aionui-team/src/prompts/team_guide.rs index 737f047fd..6f47eab0b 100644 --- a/crates/aionui-team/src/prompts/team_guide.rs +++ b/crates/aionui-team/src/prompts/team_guide.rs @@ -55,7 +55,7 @@ If case 2 applies, ask at most once whether the user wants to bring in a Team. K | Tester | Write and run tests | Suitable assistant | (model from list) | 4. **Output the table as a normal text message and END YOUR TURN.** Do NOT call `aion_create_team` or any other tool (including ask_user) in this turn. Wait for the user to reply in their next message with explicit confirmation (e.g. \"ok\", \"go ahead\", \"确认\") before proceeding. 5. After user confirms → call `aion_create_team`. The summary MUST include both the goal and the confirmed team configuration. (The system automatically derives the correct backend from a chosen assistant — you do NOT need to pass agentType when using assistant identities.) -6. After `aion_create_team` returns → you ARE now the team Leader. The system navigates to the team page automatically. **Immediately** use `team_spawn_agent` to create each teammate from the confirmed configuration table. Then use `team_send_message` to assign initial tasks to each spawned teammate. Do NOT end your turn until all teammates are spawned and tasked. +6. After `aion_create_team` returns → you ARE now the team Leader. The system navigates to the team page automatically. First call `team_list_assistants` if you need the real assistant catalog for the confirmed lineup, and only use returned assistant_id values with `team_spawn_agent`. Then use `team_send_message` to assign initial tasks to each spawned teammate. Do NOT end your turn until all teammates are spawned and tasked. 7. User declines or wants changes → adjust or proceed solo. Do not mention Team again unless the user asks. ### Tool constraint @@ -127,7 +127,7 @@ If case 2 applies, ask at most once whether the user wants to bring in a Team. K | Tester | Write and run tests | Suitable assistant | (model from list) |\n\ 4. **Output the table as a normal text message and END YOUR TURN.** Do NOT call `aion_create_team` or any other tool (including ask_user) in this turn. Wait for the user to reply in their next message with explicit confirmation (e.g. \"ok\", \"go ahead\", \"确认\") before proceeding.\n\ 5. After user confirms → call `aion_create_team`. The summary MUST include both the goal and the confirmed team configuration. (The system automatically derives the correct backend from a chosen assistant — you do NOT need to pass agentType when using assistant identities.)\n\ -6. After `aion_create_team` returns → you ARE now the team Leader. The system navigates to the team page automatically. **Immediately** use `team_spawn_agent` to create each teammate from the confirmed configuration table. Then use `team_send_message` to assign initial tasks to each spawned teammate. Do NOT end your turn until all teammates are spawned and tasked.\n\ +6. After `aion_create_team` returns → you ARE now the team Leader. The system navigates to the team page automatically. First call `team_list_assistants` if you need the real assistant catalog for the confirmed lineup, and only use returned assistant_id values with `team_spawn_agent`. Then use `team_send_message` to assign initial tasks to each spawned teammate. Do NOT end your turn until all teammates are spawned and tasked.\n\ 7. User declines or wants changes → adjust or proceed solo. Do not mention Team again unless the user asks.\n\ \n\ ### Tool constraint\n\ diff --git a/crates/aionui-team/src/service.rs b/crates/aionui-team/src/service.rs index e8425a02b..0570053c8 100644 --- a/crates/aionui-team/src/service.rs +++ b/crates/aionui-team/src/service.rs @@ -919,7 +919,6 @@ impl TeamSessionService { to_slot_id: &str, content: &str, ) -> Result<(), TeamError> { - self.require_active_team_run_for_team_work(team_id).await?; let session = { let entry = self .sessions @@ -954,6 +953,19 @@ impl TeamSessionService { .notify_reserved_wake_for_team_work(slot_id, target_role, source); } + pub(crate) fn notify_mailbox_only_wake(&self, team_id: &str, slot_id: &str, source: TeamWakeSource) { + let Some(entry) = self.sessions.get(team_id) else { + warn!( + team_id, + slot_id, + wake_source = %source, + "mailbox-only wake notify skipped because session is missing" + ); + return; + }; + entry.session.notify_mailbox_only_wake(slot_id, source); + } + pub(crate) async fn require_active_team_run_for_team_work(&self, team_id: &str) -> Result<(), TeamError> { let entry = self .sessions diff --git a/crates/aionui-team/src/service/spawn_support.rs b/crates/aionui-team/src/service/spawn_support.rs index a1fb881ef..87792cc72 100644 --- a/crates/aionui-team/src/service/spawn_support.rs +++ b/crates/aionui-team/src/service/spawn_support.rs @@ -297,7 +297,6 @@ impl TeamSessionService { caller_slot_id: &str, req: crate::session::SpawnAgentRequest, ) -> Result { - self.require_active_team_run_for_team_work(team_id).await?; let entry = self .sessions .get(team_id) diff --git a/crates/aionui-team/src/session.rs b/crates/aionui-team/src/session.rs index 88989c313..48ad83034 100644 --- a/crates/aionui-team/src/session.rs +++ b/crates/aionui-team/src/session.rs @@ -57,6 +57,11 @@ pub struct SpawnAgentRequest { pub model: Option, } +enum SpawnWakePlan { + RunScoped(TeamRunTargetRole), + MailboxOnly, +} + pub struct TeamSession { team: Team, scheduler: Arc, @@ -523,7 +528,7 @@ impl TeamSession { ); } - self.wake_agent_for_team_work( + self.direct_or_run_scoped_wake( to_slot_id, TeamWakeSource::McpSendMessage, Some(mailbox_message.id.clone()), @@ -559,6 +564,41 @@ impl TeamSession { Ok(()) } + async fn direct_or_run_scoped_wake( + &self, + slot_id: &str, + source: TeamWakeSource, + trigger_message_id: Option, + ) -> Result<(), TeamError> { + if self.team_run_manager.active_run_id().await.is_some() { + return self.wake_agent_for_team_work(slot_id, source, trigger_message_id).await; + } + self.notify_mailbox_only_wake(slot_id, source); + Ok(()) + } + + pub(crate) fn notify_mailbox_only_wake(&self, slot_id: &str, source: TeamWakeSource) { + if self.event_loops.has(slot_id) { + self.event_loops.notify(slot_id); + info!( + team_id = %self.team.id, + slot_id, + wake_source = %source, + wake_policy = "mailbox_only", + "team mailbox-only wake notified" + ); + return; + } + + info!( + team_id = %self.team.id, + slot_id, + wake_source = %source, + wake_policy = "mailbox_only_deferred", + "team mailbox-only wake deferred because event loop is not registered" + ); + } + pub(crate) async fn reserve_wake_for_team_work( &self, slot_id: &str, @@ -1046,21 +1086,33 @@ impl TeamSession { ) .await?; - let spawn_welcome_role = self - .reserve_wake_for_team_work( - &new_agent.slot_id, - TeamWakeSource::SpawnWelcome, - Some(welcome_message.id), - ) - .await? - .ok_or_else(|| TeamError::InvalidRequest("spawn welcome wake was suppressed".into()))?; - info!( - team_id = %self.team.id, - slot_id = %new_agent.slot_id, - target_role = ?spawn_welcome_role, - wake_source = %TeamWakeSource::SpawnWelcome, - "spawn welcome wake reserved before runtime attach" - ); + let spawn_wake_plan = if self.team_run_manager.active_run_id().await.is_some() { + let spawn_welcome_role = self + .reserve_wake_for_team_work( + &new_agent.slot_id, + TeamWakeSource::SpawnWelcome, + Some(welcome_message.id), + ) + .await? + .ok_or_else(|| TeamError::InvalidRequest("spawn welcome wake was suppressed".into()))?; + info!( + team_id = %self.team.id, + slot_id = %new_agent.slot_id, + target_role = ?spawn_welcome_role, + wake_source = %TeamWakeSource::SpawnWelcome, + "spawn welcome wake reserved before runtime attach" + ); + SpawnWakePlan::RunScoped(spawn_welcome_role) + } else { + info!( + team_id = %self.team.id, + slot_id = %new_agent.slot_id, + wake_source = %TeamWakeSource::SpawnWelcome, + wake_policy = "mailbox_only", + "spawn welcome wake will use mailbox-only delivery because no active team run exists" + ); + SpawnWakePlan::MailboxOnly + }; // Step 7: attach the CLI process and register the finish subscriber // in a background task. This involves spawning the CLI process and @@ -1111,13 +1163,19 @@ impl TeamSession { // Register the event loop for the newly spawned agent. service.register_event_loop(&team_id, &agent_clone.slot_id); - // Notify the event loop to drain the welcome message. - service.notify_reserved_wake_for_team_work( - &team_id, - &agent_clone.slot_id, - spawn_welcome_role, - TeamWakeSource::SpawnWelcome, - ); + match spawn_wake_plan { + SpawnWakePlan::RunScoped(spawn_welcome_role) => { + service.notify_reserved_wake_for_team_work( + &team_id, + &agent_clone.slot_id, + spawn_welcome_role, + TeamWakeSource::SpawnWelcome, + ); + } + SpawnWakePlan::MailboxOnly => { + service.notify_mailbox_only_wake(&team_id, &agent_clone.slot_id, TeamWakeSource::SpawnWelcome); + } + } }); } diff --git a/crates/aionui-team/tests/e2e_team_flow.rs b/crates/aionui-team/tests/e2e_team_flow.rs index 0f6ec8e59..f15572131 100644 --- a/crates/aionui-team/tests/e2e_team_flow.rs +++ b/crates/aionui-team/tests/e2e_team_flow.rs @@ -734,7 +734,7 @@ async fn wait_until_turn_count(turn_requests: &Arc>> /// Verifies: /// - TeamSession::start succeeds /// - MCP TCP server is reachable -/// - tools/list returns all 10 expected tools +/// - tools/list returns all 11 expected tools #[tokio::test] async fn s1a_mcp_server_starts_and_tools_available() { let (session, _tm, _repo, _sent) = setup_session().await; @@ -750,12 +750,13 @@ async fn s1a_mcp_server_starts_and_tools_available() { let resp = tcp_recv(&mut stream).await; let tools = resp["result"]["tools"].as_array().expect("tools array"); - assert_eq!(tools.len(), 10, "expected exactly 10 MCP tools, got {}", tools.len()); + assert_eq!(tools.len(), 11, "expected exactly 11 MCP tools, got {}", tools.len()); let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); assert!(names.contains(&"team_send_message"), "missing team_send_message"); assert!(names.contains(&"team_members"), "missing team_members"); assert!(names.contains(&"team_task_create"), "missing team_task_create"); + assert!(names.contains(&"team_list_assistants"), "missing team_list_assistants"); session.stop(); } diff --git a/crates/aionui-team/tests/mcp_server_integration.rs b/crates/aionui-team/tests/mcp_server_integration.rs index 3e41c1590..e6046d255 100644 --- a/crates/aionui-team/tests/mcp_server_integration.rs +++ b/crates/aionui-team/tests/mcp_server_integration.rs @@ -182,7 +182,7 @@ async fn mc1_correct_token_connects() { send_request(&mut stream, &req).await; let resp = read_response(&mut stream).await; let tools = resp["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 10); + assert_eq!(tools.len(), 11); env.server.stop(); } @@ -242,7 +242,7 @@ async fn mc3_no_token_rejected() { // --------------------------------------------------------------------------- #[tokio::test] -async fn tools_list_returns_all_10_tools() { +async fn tools_list_returns_all_11_tools() { let env = setup().await; let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await; @@ -254,7 +254,7 @@ async fn tools_list_returns_all_10_tools() { send_request(&mut stream, &req).await; let resp = read_response(&mut stream).await; let tools = resp["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 10); + assert_eq!(tools.len(), 11); let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); assert!(names.contains(&"team_send_message")); @@ -265,6 +265,7 @@ async fn tools_list_returns_all_10_tools() { assert!(names.contains(&"team_members")); assert!(names.contains(&"team_rename_agent")); assert!(names.contains(&"team_shutdown_agent")); + assert!(names.contains(&"team_list_assistants")); assert!(names.contains(&"team_list_models")); assert!(names.contains(&"team_describe_assistant")); diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index ac488dfe2..6d51808ff 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -24,6 +24,7 @@ use aionui_db::{ }; use aionui_realtime::EventBroadcaster; +use aionui_team::TeamSessionService; use aionui_team::ports::{ AgentTurnCancellationPort, AgentTurnExecutionError, AgentTurnExecutionPort, AgentTurnOutcome, AgentTurnRequest, AgentTurnStarted, AgentTurnStatus, TeamConversationBindingLookup, TeamConversationLookupPort, @@ -33,7 +34,6 @@ use aionui_team::{ TeamConversationAdoptRequest, TeamConversationCreateRequest, TeamConversationCreateResult, TeamConversationProvisioningPort, TeamProjectionMessageStore, }; -use aionui_team::{TeamError, TeamSessionService}; use common::MockTeamRepo; // --------------------------------------------------------------------------- @@ -2923,8 +2923,61 @@ async fn es1_ensure_session_creates_session() { } #[tokio::test] -async fn spawn_agent_in_session_rejects_without_active_team_run_before_persisting_agent() { - let svc = setup(); +async fn spawn_agent_in_session_succeeds_without_active_team_run() { + let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { + row: AssistantDefinitionRow { + definition_id: "def-spawn-worker".into(), + assistant_key: "assistant-worker".into(), + source: "user".into(), + owner_type: "user".into(), + source_ref: None, + source_version: None, + source_hash: None, + name: "Worker Assistant".into(), + name_i18n: "{}".into(), + description: None, + description_i18n: "{}".into(), + avatar_type: "emoji".into(), + avatar_value: Some("🤖".into()), + agent_backend: "claude".into(), + rule_resource_type: "inline".into(), + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]".into(), + recommended_prompts_i18n: "{}".into(), + default_model_mode: "auto".into(), + default_model_value: None, + default_permission_mode: "auto".into(), + default_permission_value: None, + default_skills_mode: "auto".into(), + default_skill_ids: "[]".into(), + custom_skill_names: "[]".into(), + default_disabled_builtin_skill_ids: "[]".into(), + default_mcps_mode: "auto".into(), + default_mcp_ids: "[]".into(), + created_at: 0, + updated_at: 0, + deleted_at: None, + }, + }); + let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { + row: AssistantOverlayRow { + definition_id: "def-spawn-worker".into(), + enabled: true, + sort_order: 0, + agent_backend_override: Some("codex".into()), + last_used_at: None, + created_at: 0, + updated_at: 0, + }, + }); + let agent_metadata_repo: Arc = Arc::new(StubAgentMetadataRepo::empty()); + let (svc, _team_repo, _task_manager, _conv_repo) = setup_with_factory_metadata_assistants_and_conversation_repo( + success_factory(), + agent_metadata_repo, + definition_repo, + overlay_repo, + ); let created = svc .create_team( "user1", @@ -2947,20 +3000,16 @@ async fn spawn_agent_in_session_rejects_without_active_team_run_before_persistin let req = SpawnAgentRequest { name: "Helper".into(), - assistant_id: Some("word-creator".into()), + assistant_id: Some("assistant-worker".into()), model: Some("claude-sonnet-4".into()), }; - let err = svc + let spawned = svc .spawn_agent_in_session(&created.id, &lead_slot_id, req) .await - .expect_err("spawn without active Team Run must fail before persistence"); - - assert!(matches!( - err, - TeamError::InvalidRequest(message) - if message == "no active team run for run-scoped wake" - )); + .expect("spawn without active Team Run should still succeed"); + assert_eq!(spawned.name, "Helper"); + assert_eq!(spawned.assistant_id.as_deref(), Some("assistant-worker")); let after = svc .get_team("user1", &created.id) @@ -2968,11 +3017,50 @@ async fn spawn_agent_in_session_rejects_without_active_team_run_before_persistin .expect("team should still be readable"); assert_eq!( after.assistants.len(), - created.assistants.len(), - "failed spawn must not persist a partial teammate" + created.assistants.len() + 1, + "successful spawn should persist the teammate" + ); + assert!( + after.assistants.iter().any(|agent| agent.slot_id == spawned.slot_id), + "spawned teammate must be visible in persisted team state" ); } +#[tokio::test] +async fn lead_send_agent_message_in_session_succeeds_without_active_team_run() { + let svc = setup(); + let created = svc + .create_team( + "user1", + CreateTeamRequest { + name: "Alpha".into(), + agents: two_agent_input(), + workspace: None, + }, + ) + .await + .expect("create team"); + + svc.ensure_session("user1", &created.id) + .await + .expect("session should be loaded without active Team Run"); + + let lead_slot_id = created + .leader_assistant_id + .clone() + .expect("created team should have a lead slot"); + let worker_slot_id = created + .assistants + .iter() + .find(|agent| agent.role == "teammate") + .map(|agent| agent.slot_id.clone()) + .expect("seeded teammate slot"); + + svc.send_agent_message_from_agent(&created.id, &lead_slot_id, &worker_slot_id, "Do this") + .await + .expect("leader direct message should succeed without active Team Run"); +} + #[tokio::test] async fn es2_ensure_session_is_idempotent() { let svc = setup(); From 27375537751207739ebcbc64c24c5a7623588079 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 17:38:05 +0800 Subject: [PATCH 111/135] fix(agent): stop leaking override env on management list list_management_rows now sets env to Vec::new() instead of copying meta.env, which contained merged override secrets. The management row already exposes has_command_override + env_override_key_count; the UI does not need plaintext values. The e2e test now asserts the management row's env is empty AND that the secret value "sk-x" does not appear anywhere in the management response body. --- crates/aionui-ai-agent/src/registry.rs | 2 +- crates/aionui-app/tests/agent_integration_e2e.rs | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/aionui-ai-agent/src/registry.rs b/crates/aionui-ai-agent/src/registry.rs index 984205bf2..a38ce5c61 100644 --- a/crates/aionui-ai-agent/src/registry.rs +++ b/crates/aionui-ai-agent/src/registry.rs @@ -295,7 +295,7 @@ impl AgentRegistry { installed: meta.available, command: meta.command, args: meta.args, - env: meta.env, + env: Vec::new(), native_skills_dirs: meta.native_skills_dirs, behavior_policy: meta.behavior_policy, yolo_id: meta.yolo_id, diff --git a/crates/aionui-app/tests/agent_integration_e2e.rs b/crates/aionui-app/tests/agent_integration_e2e.rs index 69b9dd420..a0fd22e7d 100644 --- a/crates/aionui-app/tests/agent_integration_e2e.rs +++ b/crates/aionui-app/tests/agent_integration_e2e.rs @@ -638,6 +638,7 @@ async fn agent_overrides_roundtrip_and_management_summary() { // management row: safe fields, blocked PATH not counted let mreq = get_with_token("/api/agents/management", &token); let mbody = body_json(app.clone().oneshot(mreq).await.unwrap()).await; + let mbody_str = serde_json::to_string(&mbody).unwrap(); let row = mbody["data"] .as_array() .unwrap() @@ -647,8 +648,12 @@ async fn agent_overrides_roundtrip_and_management_summary() { assert_eq!(row["has_command_override"], true); assert_eq!(row["env_override_key_count"], 1); // PATH excluded assert!( - row.get("env_override").is_none(), - "management row must not carry env plaintext" + row["env"].as_array().map_or(true, |arr| arr.is_empty()), + "management row env must be empty or absent" + ); + assert!( + !mbody_str.contains("sk-x"), + "management response must not leak secret values" ); // GET overrides: plaintext echo From 7e7dc4d0c8a3ca52a4fe05bfe3dec1e015e4f300 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 17:38:12 +0800 Subject: [PATCH 112/135] fix(agent): clear needs_auth on session success Added record_session_success to AgentAvailabilityFeedbackPort trait and AgentAvailabilityService. It persists a session-kind available snapshot, which clears needs_auth and updates last_success_at. Turn orchestrator now calls record_agent_session_success when send_message succeeds, mirroring the existing session failure path. Test: record_session_success_clears_needs_auth verifies that a needs_auth state set via session failure is cleared by session success. --- .../src/services/availability/mod.rs | 87 +++++++++++++++++ .../src/turn_orchestrator.rs | 95 +++++++++++-------- 2 files changed, 145 insertions(+), 37 deletions(-) diff --git a/crates/aionui-ai-agent/src/services/availability/mod.rs b/crates/aionui-ai-agent/src/services/availability/mod.rs index 4920fe253..ff0420ebe 100644 --- a/crates/aionui-ai-agent/src/services/availability/mod.rs +++ b/crates/aionui-ai-agent/src/services/availability/mod.rs @@ -28,6 +28,7 @@ const DEFAULT_SCHEDULED_INTERVAL: Duration = Duration::from_secs(300); #[async_trait::async_trait] pub trait AgentAvailabilityFeedbackPort: Send + Sync { async fn record_session_failure(&self, agent_id: &str, code: &str, message: &str) -> Result<(), AgentError>; + async fn record_session_success(&self, agent_id: &str) -> Result<(), AgentError>; } struct AvailabilitySnapshot { @@ -128,6 +129,19 @@ impl AgentAvailabilityService { self.persist_snapshot(agent_id, &snapshot).await } + pub async fn record_session_success(&self, agent_id: &str) -> Result<(), AgentError> { + let checked_at = now_ms(); + let snapshot = AvailabilitySnapshot { + status: "available", + kind: "session", + error_code: None, + error_message: None, + latency_ms: 0, + checked_at, + }; + self.persist_snapshot(agent_id, &snapshot).await + } + pub async fn management_row_by_id(&self, id: &str) -> Option { self.registry .list_management_rows() @@ -361,6 +375,10 @@ impl AgentAvailabilityFeedbackPort for AgentAvailabilityService { async fn record_session_failure(&self, agent_id: &str, code: &str, message: &str) -> Result<(), AgentError> { AgentAvailabilityService::record_session_failure(self, agent_id, code, message).await } + + async fn record_session_success(&self, agent_id: &str) -> Result<(), AgentError> { + AgentAvailabilityService::record_session_success(self, agent_id).await + } } #[cfg(test)] @@ -655,6 +673,75 @@ mod tests { assert!(row.last_check_at.is_some()); } + #[tokio::test] + async fn record_session_success_clears_needs_auth() { + let db = init_database_memory().await.unwrap(); + let repo: Arc = Arc::new(SqliteAgentMetadataRepository::new(db.pool().clone())); + + repo.upsert(&UpsertAgentMetadataParams { + id: "auth-clear-agent", + icon: None, + name: "Auth Clear Agent", + name_i18n: None, + description: None, + description_i18n: None, + backend: Some("github"), + agent_type: "acp", + agent_source: "custom", + agent_source_info: Some(r#"{"binary_name":"gh"}"#), + enabled: true, + command: Some("gh"), + args: Some("[]"), + env: Some("[]"), + native_skills_dirs: None, + behavior_policy: None, + yolo_id: None, + agent_capabilities: None, + auth_methods: None, + config_options: None, + available_modes: None, + available_models: None, + available_commands: None, + sort_order: 100, + }) + .await + .unwrap(); + + let registry = AgentRegistry::new(repo); + registry.hydrate().await.unwrap(); + + let service = AgentAvailabilityService::new(registry.clone(), std::env::temp_dir()); + + // First, set needs_auth via session failure + service + .record_session_failure("auth-clear-agent", "user_agent_auth_required", "needs login") + .await + .unwrap(); + + let row = service + .list_management_rows() + .await + .into_iter() + .find(|item| item.id == "auth-clear-agent") + .unwrap(); + assert_eq!(row.status, AgentManagementStatus::NeedsAuth); + assert_eq!(row.last_check_status, Some(AgentSnapshotCheckStatus::NeedsAuth)); + + // Now record session success + service.record_session_success("auth-clear-agent").await.unwrap(); + + let row = service + .list_management_rows() + .await + .into_iter() + .find(|item| item.id == "auth-clear-agent") + .unwrap(); + assert_eq!(row.status, AgentManagementStatus::Available); + assert_eq!(row.last_check_status, Some(AgentSnapshotCheckStatus::Available)); + assert_eq!(row.last_check_kind, Some(AgentSnapshotCheckKind::Session)); + assert!(row.last_success_at.is_some()); + } + #[tokio::test] async fn managed_builtin_probe_checks_primary_binary_before_running_bridge_command() { let db = init_database_memory().await.unwrap(); diff --git a/crates/aionui-conversation/src/turn_orchestrator.rs b/crates/aionui-conversation/src/turn_orchestrator.rs index 2e55d6aff..536cb7f74 100644 --- a/crates/aionui-conversation/src/turn_orchestrator.rs +++ b/crates/aionui-conversation/src/turn_orchestrator.rs @@ -169,47 +169,52 @@ impl ConversationTurnOrchestrator { let (send_error_tx, send_error_rx) = oneshot::channel(); tokio::spawn(async move { - if let Err(e) = send_agent.send_message(current_send).await { - let failure_message = availability_failure_message(&e); - let code = if e.code() == Some(AgentErrorCode::UserAgentAuthRequired) { - "user_agent_auth_required" - } else { - "session_send_failed" - }; - record_agent_session_failure( - &feedback_service, - feedback_agent_id.as_deref(), - code, - &failure_message, - ) - .await; - let task_status = send_agent.status(); - let agent_type = send_agent.agent_type(); - error!( - conversation_id = %conv_id_send, - turn_id = %turn_id_for_send, - ?agent_type, - ?task_status, - error = %ErrorChain(&e), - "Agent send_message failed" - ); - if task_status == Some(ConversationStatus::Finished) { - debug!( - conversation_id = %conv_id_send, - turn_id = %turn_id_for_send, - ?agent_type, - "Agent send_message failure already published runtime terminal; skipping fallback stream error" - ); - } else { - warn!( + match send_agent.send_message(current_send).await { + Ok(()) => { + record_agent_session_success(&feedback_service, feedback_agent_id.as_deref()).await; + } + Err(e) => { + let failure_message = availability_failure_message(&e); + let code = if e.code() == Some(AgentErrorCode::UserAgentAuthRequired) { + "user_agent_auth_required" + } else { + "session_send_failed" + }; + record_agent_session_failure( + &feedback_service, + feedback_agent_id.as_deref(), + code, + &failure_message, + ) + .await; + let task_status = send_agent.status(); + let agent_type = send_agent.agent_type(); + error!( conversation_id = %conv_id_send, turn_id = %turn_id_for_send, ?agent_type, - code = ?e.code(), - ownership = ?e.ownership(), - "Agent send_message returned error without runtime terminal; injecting fallback stream error" + ?task_status, + error = %ErrorChain(&e), + "Agent send_message failed" ); - let _ = send_error_tx.send(e); + if task_status == Some(ConversationStatus::Finished) { + debug!( + conversation_id = %conv_id_send, + turn_id = %turn_id_for_send, + ?agent_type, + "Agent send_message failure already published runtime terminal; skipping fallback stream error" + ); + } else { + warn!( + conversation_id = %conv_id_send, + turn_id = %turn_id_for_send, + ?agent_type, + code = ?e.code(), + ownership = ?e.ownership(), + "Agent send_message returned error without runtime terminal; injecting fallback stream error" + ); + let _ = send_error_tx.send(e); + } } } }); @@ -311,3 +316,19 @@ async fn record_agent_session_failure( ); } } + +async fn record_agent_session_success(service: &ConversationService, agent_id: Option<&str>) { + let Some(agent_id) = agent_id else { + return; + }; + let Some(feedback) = service.agent_availability_feedback() else { + return; + }; + if let Err(error) = feedback.record_session_success(agent_id).await { + warn!( + agent_id, + error = %ErrorChain(&error), + "Failed to record agent availability session success" + ); + } +} From c4072a9d4ebe22576de57334628739b4fd36912a Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 18:09:21 +0800 Subject: [PATCH 113/135] fix(team): structure assistant-first errors for i18n --- crates/aionui-ai-agent/src/factory/acp.rs | 1 + .../src/manager/acp/codex_sandbox.rs | 1 + .../src/manager/acp/mode_normalize.rs | 1 + .../src/protocol/cli_detect.rs | 1 + crates/aionui-ai-agent/src/registry.rs | 53 ++++- crates/aionui-ai-agent/src/registry_tests.rs | 8 + .../src/services/availability/mod.rs | 1 + .../tests/agent_availability_integration.rs | 5 + .../aionui-api-types/src/agent_discovery.rs | 5 + crates/aionui-team/src/error.rs | 85 +++++++ crates/aionui-team/src/guide/server.rs | 51 +++- crates/aionui-team/src/mcp/server.rs | 224 ++++++++++++------ crates/aionui-team/src/routes.rs | 45 +++- 13 files changed, 399 insertions(+), 82 deletions(-) diff --git a/crates/aionui-ai-agent/src/factory/acp.rs b/crates/aionui-ai-agent/src/factory/acp.rs index 99462834b..8f7c08a39 100644 --- a/crates/aionui-ai-agent/src/factory/acp.rs +++ b/crates/aionui-ai-agent/src/factory/acp.rs @@ -681,6 +681,7 @@ mod tests { last_check_kind: None, last_check_error_code: None, last_check_error_message: None, + last_check_error_details: None, last_check_guidance: None, last_check_latency_ms: None, last_check_at: None, diff --git a/crates/aionui-ai-agent/src/manager/acp/codex_sandbox.rs b/crates/aionui-ai-agent/src/manager/acp/codex_sandbox.rs index fd1bb8af7..db778572c 100644 --- a/crates/aionui-ai-agent/src/manager/acp/codex_sandbox.rs +++ b/crates/aionui-ai-agent/src/manager/acp/codex_sandbox.rs @@ -240,6 +240,7 @@ mod tests { last_check_kind: None, last_check_error_code: None, last_check_error_message: None, + last_check_error_details: None, last_check_guidance: None, last_check_latency_ms: None, last_check_at: None, diff --git a/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs b/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs index 4a46ef1df..6d85e1629 100644 --- a/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs +++ b/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs @@ -75,6 +75,7 @@ mod tests { last_check_kind: None, last_check_error_code: None, last_check_error_message: None, + last_check_error_details: None, last_check_guidance: None, last_check_latency_ms: None, last_check_at: None, diff --git a/crates/aionui-ai-agent/src/protocol/cli_detect.rs b/crates/aionui-ai-agent/src/protocol/cli_detect.rs index 449ad2d90..c9d096549 100644 --- a/crates/aionui-ai-agent/src/protocol/cli_detect.rs +++ b/crates/aionui-ai-agent/src/protocol/cli_detect.rs @@ -84,6 +84,7 @@ mod tests { last_check_kind: Some(AgentSnapshotCheckKind::Startup), last_check_error_code: None, last_check_error_message: None, + last_check_error_details: None, last_check_guidance: None, last_check_latency_ms: None, last_check_at: None, diff --git a/crates/aionui-ai-agent/src/registry.rs b/crates/aionui-ai-agent/src/registry.rs index a38ce5c61..5cc623a62 100644 --- a/crates/aionui-ai-agent/src/registry.rs +++ b/crates/aionui-ai-agent/src/registry.rs @@ -27,7 +27,7 @@ use aionui_runtime::{ ManagedAcpToolId, RuntimeCommandProbe, probe_managed_acp_tool_supported, probe_node_runtime_supported, probe_runtime_command, resolve_command_path, }; -use serde_json::Value; +use serde_json::{Value, json}; use tokio::sync::{RwLock, mpsc}; use tracing::{debug, info, warn}; @@ -306,6 +306,7 @@ impl AgentRegistry { last_check_kind: meta.last_check_kind, last_check_error_code: diagnostics.error_code, last_check_error_message: diagnostics.error_message, + last_check_error_details: diagnostics.details, last_check_guidance: diagnostics.guidance, last_check_latency_ms: meta.last_check_latency_ms, last_check_at: meta.last_check_at, @@ -444,6 +445,7 @@ fn decode_row(row: AgentMetadataRow) -> Option<(AgentMetadata, Option AgentManagementStatus { struct ManagementDiagnostics { error_code: Option, error_message: Option, + details: Option, guidance: Option, } @@ -643,6 +646,10 @@ fn derive_management_diagnostics(meta: &AgentMetadata, status: AgentManagementSt .last_check_error_message .clone() .or_else(|| derived_reason.as_ref().map(|reason| reason.to_string())); + let details = derived_reason + .as_ref() + .and_then(diagnostic_details_for_unavailable_reason) + .or_else(|| error_code.as_deref().and_then(|code| diagnostic_details_for_snapshot_code(meta, code))); let guidance = meta.last_check_guidance.clone().or_else(|| { if let Some(reason) = derived_reason.as_ref() { Some(guidance_for_unavailable_reason(reason)) @@ -658,10 +665,54 @@ fn derive_management_diagnostics(meta: &AgentMetadata, status: AgentManagementSt ManagementDiagnostics { error_code, error_message, + details, guidance, } } +fn diagnostic_details_for_snapshot_code(meta: &AgentMetadata, error_code: &str) -> Option { + match error_code { + "command_not_found" => Some(json!({ + "code": error_code, + "command": meta + .agent_source_info + .binary_name + .as_deref() + .or(meta.command.as_deref()) + .unwrap_or("command"), + })), + "acp_init_failed" | "health_check_failed" | "session_send_failed" => Some(json!({ + "code": error_code, + "agent_name": meta.name, + "backend": meta.backend, + })), + _ => Some(json!({ "code": error_code })), + } +} + +fn diagnostic_details_for_unavailable_reason(reason: &UnavailableReason) -> Option { + match reason { + UnavailableReason::Disabled => Some(json!({ "code": "disabled" })), + UnavailableReason::NoCommand => Some(json!({ "code": "no_command" })), + UnavailableReason::BridgeMissing { bridge } => Some(json!({ + "code": "bridge_missing", + "command": bridge, + })), + UnavailableReason::PrimaryMissing { binary } => Some(json!({ + "code": "primary_missing", + "command": binary, + })), + UnavailableReason::CommandMissing { command } => Some(json!({ + "code": "command_missing", + "command": command, + })), + UnavailableReason::ManagedRuntimeUnavailable { resource, .. } => Some(json!({ + "code": "managed_runtime_unavailable", + "resource": resource, + })), + } +} + fn unavailable_reason_code(reason: &UnavailableReason) -> String { match reason { UnavailableReason::Disabled => "disabled", diff --git a/crates/aionui-ai-agent/src/registry_tests.rs b/crates/aionui-ai-agent/src/registry_tests.rs index 6165b3019..afb427193 100644 --- a/crates/aionui-ai-agent/src/registry_tests.rs +++ b/crates/aionui-ai-agent/src/registry_tests.rs @@ -36,6 +36,7 @@ fn probe_resolved_command_accepts_bare_npx_when_managed_runtime_is_supported() { last_check_kind: None, last_check_error_code: None, last_check_error_message: None, + last_check_error_details: None, last_check_guidance: None, last_check_latency_ms: None, last_check_at: None, @@ -87,6 +88,7 @@ fn probe_resolved_command_requires_primary_binary_for_builtin_managed_claude() { last_check_kind: None, last_check_error_code: None, last_check_error_message: None, + last_check_error_details: None, last_check_guidance: None, last_check_latency_ms: None, last_check_at: None, @@ -141,6 +143,7 @@ fn probe_resolved_command_requires_primary_binary_for_builtin_managed_codex() { last_check_kind: None, last_check_error_code: None, last_check_error_message: None, + last_check_error_details: None, last_check_guidance: None, last_check_latency_ms: None, last_check_at: None, @@ -214,4 +217,9 @@ async fn management_rows_derive_missing_diagnostics_from_probe_reason() { .as_deref() .is_some_and(|guidance| guidance.contains("PATH")) ); + let row_json = serde_json::to_value(&row).unwrap(); + assert_eq!( + row_json["last_check_error_details"]["command"].as_str(), + Some("definitely-missing-cli") + ); } diff --git a/crates/aionui-ai-agent/src/services/availability/mod.rs b/crates/aionui-ai-agent/src/services/availability/mod.rs index ff0420ebe..7ae0642b5 100644 --- a/crates/aionui-ai-agent/src/services/availability/mod.rs +++ b/crates/aionui-ai-agent/src/services/availability/mod.rs @@ -784,6 +784,7 @@ mod tests { last_check_kind: None, last_check_error_code: None, last_check_error_message: None, + last_check_error_details: None, last_check_guidance: None, last_check_latency_ms: None, last_check_at: None, diff --git a/crates/aionui-ai-agent/tests/agent_availability_integration.rs b/crates/aionui-ai-agent/tests/agent_availability_integration.rs index eca444e56..0067a5604 100644 --- a/crates/aionui-ai-agent/tests/agent_availability_integration.rs +++ b/crates/aionui-ai-agent/tests/agent_availability_integration.rs @@ -106,6 +106,11 @@ async fn management_rows_derive_missing_available_and_unavailable_statuses() { ); assert_eq!(unavailable.last_check_kind, Some(AgentSnapshotCheckKind::Manual)); assert_eq!(unavailable.last_check_error_code.as_deref(), Some("auth_required")); + let unavailable_json = serde_json::to_value(unavailable).unwrap(); + assert_eq!( + unavailable_json["last_check_error_details"]["code"].as_str(), + Some("auth_required") + ); let available = rows.iter().find(|row| row.id == "agent-available").unwrap(); assert_eq!(available.status, AgentManagementStatus::Available); diff --git a/crates/aionui-api-types/src/agent_discovery.rs b/crates/aionui-api-types/src/agent_discovery.rs index fe5f48e8e..630e86340 100644 --- a/crates/aionui-api-types/src/agent_discovery.rs +++ b/crates/aionui-api-types/src/agent_discovery.rs @@ -224,6 +224,8 @@ pub struct AgentMetadata { #[serde(default, skip_serializing_if = "Option::is_none")] pub last_check_error_message: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_check_error_details: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub last_check_guidance: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_check_latency_ms: Option, @@ -292,6 +294,8 @@ pub struct AgentManagementRow { #[serde(default, skip_serializing_if = "Option::is_none")] pub last_check_error_message: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_check_error_details: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub last_check_guidance: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_check_latency_ms: Option, @@ -355,6 +359,7 @@ mod tests { last_check_kind: None, last_check_error_code: None, last_check_error_message: None, + last_check_error_details: None, last_check_guidance: None, last_check_latency_ms: None, last_check_at: None, diff --git a/crates/aionui-team/src/error.rs b/crates/aionui-team/src/error.rs index a96be7dc5..8690163d8 100644 --- a/crates/aionui-team/src/error.rs +++ b/crates/aionui-team/src/error.rs @@ -1,3 +1,5 @@ +use serde_json::{Value, json}; + #[derive(Debug, thiserror::Error)] pub enum TeamError { #[error("Team not found: {0}")] @@ -46,6 +48,66 @@ pub enum TeamError { Json(#[from] serde_json::Error), } +#[derive(Debug, Clone, PartialEq)] +pub struct TeamPublicError { + pub code: &'static str, + pub details: Option, +} + +impl TeamPublicError { + fn new(code: &'static str, details: Option) -> Self { + Self { code, details } + } +} + +pub fn classify_public_error(message: &str) -> Option { + if matches!( + message, + "Missing required field: assistant_id" + | "spawn_agent.assistant_id is required" + | "assistant_id is required when the caller conversation is not assistant-backed" + ) { + return Some(TeamPublicError::new( + "TEAM_ASSISTANT_ID_REQUIRED", + Some(json!({ "field": "assistant_id" })), + )); + } + + if let Some(assistant_id) = message.strip_prefix("Preset assistant not found: ") { + return Some(TeamPublicError::new( + "TEAM_ASSISTANT_NOT_FOUND", + Some(json!({ "assistant_id": assistant_id })), + )); + } + + for field in ["backend", "agent_type", "custom_agent_id"] { + if message == format!("{field} is no longer accepted; use assistant_id") { + return Some(TeamPublicError::new( + "TEAM_ASSISTANT_FIELD_UNSUPPORTED", + Some(json!({ + "field": field, + "required_field": "assistant_id", + })), + )); + } + } + + if message == "team_list_assistants does not accept arguments" { + return Some(TeamPublicError::new( + "TEAM_TOOL_ARGUMENTS_NOT_ALLOWED", + Some(json!({ + "tool": "team_list_assistants", + })), + )); + } + + if message == "Team service not available" { + return Some(TeamPublicError::new("TEAM_SERVICE_UNAVAILABLE", None)); + } + + None +} + #[cfg(test)] mod tests { use super::*; @@ -60,4 +122,27 @@ mod tests { "Team slot is busy: lead-1" ); } + + #[test] + fn classify_public_error_recognizes_branch_assistant_first_failures() { + let required = classify_public_error("Missing required field: assistant_id").expect("classified"); + assert_eq!(required.code, "TEAM_ASSISTANT_ID_REQUIRED"); + assert_eq!(required.details, Some(json!({ "field": "assistant_id" }))); + + let assistant = + classify_public_error("Preset assistant not found: bare:abcd1234").expect("assistant lookup"); + assert_eq!(assistant.code, "TEAM_ASSISTANT_NOT_FOUND"); + assert_eq!(assistant.details, Some(json!({ "assistant_id": "bare:abcd1234" }))); + + let legacy = + classify_public_error("backend is no longer accepted; use assistant_id").expect("legacy field"); + assert_eq!(legacy.code, "TEAM_ASSISTANT_FIELD_UNSUPPORTED"); + assert_eq!( + legacy.details, + Some(json!({ + "field": "backend", + "required_field": "assistant_id", + })) + ); + } } diff --git a/crates/aionui-team/src/guide/server.rs b/crates/aionui-team/src/guide/server.rs index eba1bcc59..6f092d780 100644 --- a/crates/aionui-team/src/guide/server.rs +++ b/crates/aionui-team/src/guide/server.rs @@ -10,6 +10,7 @@ use tokio::net::TcpListener; use tokio::sync::RwLock; use tracing::{debug, info, warn}; +use crate::error::classify_public_error; use crate::service::TeamSessionService; use crate::types::TeammateRole; @@ -188,6 +189,26 @@ async fn handle_tool_request( resp } +fn error_response(message: impl Into) -> serde_json::Value { + let message = message.into(); + if let Some(public) = classify_public_error(&message) { + let mut data = serde_json::json!({ + "domainCode": public.code, + }); + if let Some(details) = public.details { + data["details"] = details; + } + serde_json::json!({ + "error": { + "message": message, + "data": data, + } + }) + } else { + serde_json::json!({ "error": message }) + } +} + // --------------------------------------------------------------------------- // Tool implementations // --------------------------------------------------------------------------- @@ -240,7 +261,7 @@ async fn exec_create_team( Ok(assistant_id) => assistant_id, Err(error) => { warn!(error, "Guide HTTP: aion_create_team missing assistant identity"); - return serde_json::json!({ "error": error }); + return error_response(error); } }; @@ -261,7 +282,7 @@ async fn exec_create_team( Ok(_) => {} Err(error) => { warn!(conversation_id = conv_id, error = %error, "Guide HTTP: team binding lookup failed"); - return serde_json::json!({"error": "Failed to inspect conversation team binding."}); + return error_response("Failed to inspect conversation team binding."); } } } @@ -283,7 +304,7 @@ async fn exec_create_team( Ok(t) => t, Err(e) => { warn!(error = %e, "Guide HTTP: aion_create_team create_team failed"); - return serde_json::json!({"error": e.to_string()}); + return error_response(e.to_string()); } }; @@ -411,8 +432,24 @@ async fn exec_team_tool( serde_json::json!({"result": text}) } Err(err) => { - warn!(tool = tool_name, team_id = %team_id, error = %err, "Guide HTTP: team tool failed"); - serde_json::json!({"error": err}) + warn!(tool = tool_name, team_id = %team_id, error = %err.message, "Guide HTTP: team tool failed"); + if err.domain_code.is_some() || err.details.is_some() { + let mut data = serde_json::json!({}); + if let Some(domain_code) = err.domain_code { + data["domainCode"] = serde_json::json!(domain_code); + } + if let Some(details) = err.details { + data["details"] = details; + } + serde_json::json!({ + "error": { + "message": err.message, + "data": data, + } + }) + } else { + serde_json::json!({"error": err.message}) + } } } } @@ -1028,9 +1065,11 @@ mod tests { let body: serde_json::Value = resp.json().await.expect("guide create team error response"); assert_eq!( - body["error"].as_str(), + body["error"]["message"].as_str(), Some("assistant_id is required when the caller conversation is not assistant-backed") ); + assert_eq!(body["error"]["data"]["domainCode"].as_str(), Some("TEAM_ASSISTANT_ID_REQUIRED")); + assert_eq!(body["error"]["data"]["details"]["field"].as_str(), Some("assistant_id")); assert!(body.get("teamId").is_none()); assert!( team_repo diff --git a/crates/aionui-team/src/mcp/server.rs b/crates/aionui-team/src/mcp/server.rs index d1bc79a4f..0bd531701 100644 --- a/crates/aionui-team/src/mcp/server.rs +++ b/crates/aionui-team/src/mcp/server.rs @@ -8,7 +8,7 @@ use tokio::net::{TcpListener, TcpStream}; use tokio::sync::watch; use tracing::{debug, error, info, warn}; -use crate::error::TeamError; +use crate::error::{TeamError, classify_public_error}; use crate::events::TEAM_MCP_STATUS_EVENT; use crate::scheduler::TeammateManager; use crate::service::TeamSessionService; @@ -398,7 +398,13 @@ async fn handle_tools_call( match &result { Ok(_) => info!(team_id = %team_id, tool = %tool_name, caller = %caller_slot_id, "MCP tool call succeeded"), Err(e) => { - warn!(team_id = %team_id, tool = %tool_name, caller = %caller_slot_id, error = %e, "MCP tool call failed") + warn!( + team_id = %team_id, + tool = %tool_name, + caller = %caller_slot_id, + error = %e.message, + "MCP tool call failed" + ) } } @@ -409,13 +415,23 @@ async fn handle_tools_call( "content": [{ "type": "text", "text": content }] }), ), - Err(err_msg) => JsonRpcResponse::success( - request.id, - json!({ - "content": [{ "type": "text", "text": err_msg }], + Err(err) => { + let mut result = json!({ + "content": [{ "type": "text", "text": err.message }], "isError": true - }), - ), + }); + if err.domain_code.is_some() || err.details.is_some() { + let mut structured = json!({}); + if let Some(domain_code) = err.domain_code { + structured["domainCode"] = json!(domain_code); + } + if let Some(details) = err.details { + structured["details"] = details; + } + result["structuredContent"] = structured; + } + JsonRpcResponse::success(request.id, result) + } } } @@ -423,6 +439,31 @@ async fn handle_tools_call( // Tool dispatch // --------------------------------------------------------------------------- +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ToolCallError { + pub message: String, + pub domain_code: Option<&'static str>, + pub details: Option, +} + +impl ToolCallError { + fn from_message(message: impl Into) -> Self { + let message = message.into(); + let classified = classify_public_error(&message); + Self { + message, + domain_code: classified.as_ref().map(|value| value.code), + details: classified.and_then(|value| value.details), + } + } +} + +impl std::fmt::Display for ToolCallError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + pub(crate) async fn dispatch_tool( tool_name: &str, arguments: &Value, @@ -431,7 +472,7 @@ pub(crate) async fn dispatch_tool( team_id: &str, caller_slot_id: &str, caller_role: TeammateRole, -) -> Result { +) -> Result { match tool_name { "team_send_message" => exec_send_message(arguments, scheduler, service, team_id, caller_slot_id).await, "team_spawn_agent" => exec_spawn_agent(arguments, service, team_id, caller_slot_id, caller_role).await, @@ -446,29 +487,29 @@ pub(crate) async fn dispatch_tool( "team_list_assistants" => exec_list_assistants(arguments, service).await, "team_list_models" => exec_list_models(arguments, service).await, "team_describe_assistant" => exec_describe_assistant(arguments, service).await, - _ => Err(format!("Unknown tool: {tool_name}")), + _ => Err(ToolCallError::from_message(format!("Unknown tool: {tool_name}"))), } } -async fn exec_list_assistants(args: &Value, service: &Weak) -> Result { +async fn exec_list_assistants(args: &Value, service: &Weak) -> Result { let props = args.as_object().cloned().unwrap_or_default(); if !props.is_empty() { - return Err("team_list_assistants does not accept arguments".to_owned()); + return Err(ToolCallError::from_message("team_list_assistants does not accept arguments")); } let service = service .upgrade() - .ok_or_else(|| "Team service not available".to_owned())?; + .ok_or_else(|| ToolCallError::from_message("Team service not available"))?; let assistants = service.list_team_selectable_assistants().await; let value = json!({ "assistants": assistants }); - serde_json::to_string_pretty(&value).map_err(|e| format!("Serialization error: {e}")) + serde_json::to_string_pretty(&value).map_err(|e| ToolCallError::from_message(format!("Serialization error: {e}"))) } -async fn exec_list_models(args: &Value, service: &Weak) -> Result { +async fn exec_list_models(args: &Value, service: &Weak) -> Result { if args.get("backend").is_some() { - return Err("backend is no longer accepted; use assistant_id".to_owned()); + return Err(ToolCallError::from_message("backend is no longer accepted; use assistant_id")); } if args.get("agent_type").is_some() { - return Err("agent_type is no longer accepted; use assistant_id".to_owned()); + return Err(ToolCallError::from_message("agent_type is no longer accepted; use assistant_id")); } let assistant_id_filter = args.get("assistant_id").and_then(Value::as_str); @@ -476,31 +517,31 @@ async fn exec_list_models(args: &Value, service: &Weak) -> R Some(svc) => svc .list_models_from_db(assistant_id_filter) .await - .map_err(|error| error.to_string())?, + .map_err(|error| ToolCallError::from_message(error.to_string()))?, None => handle_team_list_models(args), }; - serde_json::to_string_pretty(&value).map_err(|e| format!("Serialization error: {e}")) + serde_json::to_string_pretty(&value).map_err(|e| ToolCallError::from_message(format!("Serialization error: {e}"))) } -async fn exec_describe_assistant(args: &Value, service: &Weak) -> Result { +async fn exec_describe_assistant(args: &Value, service: &Weak) -> Result { if args.get("custom_agent_id").is_some() { - return Err("custom_agent_id is no longer accepted; use assistant_id".to_owned()); + return Err(ToolCallError::from_message("custom_agent_id is no longer accepted; use assistant_id")); } let assistant_key = args .get("assistant_id") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) - .ok_or_else(|| "Missing required field: assistant_id".to_owned())?; + .ok_or_else(|| ToolCallError::from_message("Missing required field: assistant_id"))?; let locale = args.get("locale").and_then(Value::as_str); let service = service .upgrade() - .ok_or_else(|| "Team service not available".to_owned())?; + .ok_or_else(|| ToolCallError::from_message("Team service not available"))?; service .describe_assistant(assistant_key, locale) .await - .map_err(|error| error.to_string()) + .map_err(|error| ToolCallError::from_message(error.to_string())) } // --------------------------------------------------------------------------- @@ -527,8 +568,9 @@ async fn exec_send_message( service: &Weak, team_id: &str, caller_slot_id: &str, -) -> Result { - let input: SendMessageInput = serde_json::from_value(args.clone()).map_err(|e| format!("Invalid params: {e}"))?; +) -> Result { + let input: SendMessageInput = + serde_json::from_value(args.clone()).map_err(|e| ToolCallError::from_message(format!("Invalid params: {e}")))?; let trimmed = input.message.trim(); if trimmed == "shutdown_approved" { @@ -559,7 +601,7 @@ async fn exec_send_message( scheduler .notify_shutdown_rejected(caller_slot_id, reason) .await - .map_err(|e| e.to_string())?; + .map_err(|e| ToolCallError::from_message(e.to_string()))?; if let Some(svc) = service.upgrade() && let Err(e) = svc .wake_leader_after_recovery_message(team_id, caller_slot_id, TeamWakeSource::ShutdownRejected) @@ -580,12 +622,14 @@ async fn exec_send_message( let resolved_to = if input.to == "*" { "*".to_owned() } else { - resolve_agent_target(scheduler, &input.to).await? + resolve_agent_target(scheduler, &input.to) + .await + .map_err(ToolCallError::from_message)? }; let service = service .upgrade() - .ok_or_else(|| "Team service not available; cannot wake target".to_string())?; + .ok_or_else(|| ToolCallError::from_message("Team service not available; cannot wake target"))?; let targets = if resolved_to == "*" { scheduler @@ -602,7 +646,7 @@ async fn exec_send_message( service .send_agent_message_from_agent(team_id, caller_slot_id, target, &input.message) .await - .map_err(|e| e.to_string())?; + .map_err(|e| ToolCallError::from_message(e.to_string()))?; } Ok(format!("Message sent to {}", input.to)) @@ -614,29 +658,30 @@ async fn exec_spawn_agent( team_id: &str, caller_slot_id: &str, caller_role: TeammateRole, -) -> Result { +) -> Result { // Lead-only at the MCP dispatch layer. `TeamSession::spawn_agent` also // re-checks via `TeamError::LeaderOnly`, but the dispatch-level string // keeps the user-visible "Only Lead ..." phrasing that the MCP client // (and existing protocol tests) expect. if caller_role != TeammateRole::Lead { - return Err("Only Lead can spawn agents".into()); + return Err(ToolCallError::from_message("Only Lead can spawn agents")); } if args.get("backend").is_some() { - return Err("backend is no longer accepted; use assistant_id".into()); + return Err(ToolCallError::from_message("backend is no longer accepted; use assistant_id")); } if args.get("agent_type").is_some() { - return Err("agent_type is no longer accepted; use assistant_id".into()); + return Err(ToolCallError::from_message("agent_type is no longer accepted; use assistant_id")); } - let input: SpawnAgentInput = serde_json::from_value(args.clone()).map_err(|e| format!("Invalid params: {e}"))?; + let input: SpawnAgentInput = + serde_json::from_value(args.clone()).map_err(|e| ToolCallError::from_message(format!("Invalid params: {e}")))?; let assistant_id = input .assistant_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_owned) - .ok_or_else(|| "Missing required field: assistant_id".to_owned())?; + .ok_or_else(|| ToolCallError::from_message("Missing required field: assistant_id"))?; // Requested name — normalization / emptiness / uniqueness live in // `TeamSession::spawn_agent` so we do not double-validate here. @@ -650,17 +695,18 @@ async fn exec_spawn_agent( let service = service .upgrade() - .ok_or_else(|| "Team service not available; cannot spawn agent".to_string())?; + .ok_or_else(|| ToolCallError::from_message("Team service not available; cannot spawn agent"))?; service .spawn_agent_in_session(team_id, caller_slot_id, req) .await .map(|agent| format!("Agent '{}' spawned (slot_id={})", agent.name, agent.slot_id)) - .map_err(|e| e.to_string()) + .map_err(|e| ToolCallError::from_message(e.to_string())) } -async fn exec_task_create(args: &Value, scheduler: &TeammateManager) -> Result { - let input: TaskCreateInput = serde_json::from_value(args.clone()).map_err(|e| format!("Invalid params: {e}"))?; +async fn exec_task_create(args: &Value, scheduler: &TeammateManager) -> Result { + let input: TaskCreateInput = + serde_json::from_value(args.clone()).map_err(|e| ToolCallError::from_message(format!("Invalid params: {e}")))?; let action = crate::scheduler::SchedulerAction::TaskCreate { subject: input.subject.clone(), @@ -671,13 +717,14 @@ async fn exec_task_create(args: &Value, scheduler: &TeammateManager) -> Result Result { - let input: TaskUpdateInput = serde_json::from_value(args.clone()).map_err(|e| format!("Invalid params: {e}"))?; +async fn exec_task_update(args: &Value, scheduler: &TeammateManager) -> Result { + let input: TaskUpdateInput = + serde_json::from_value(args.clone()).map_err(|e| ToolCallError::from_message(format!("Invalid params: {e}")))?; let action = crate::scheduler::SchedulerAction::TaskUpdate { task_id: input.task_id.clone(), @@ -689,13 +736,16 @@ async fn exec_task_update(args: &Value, scheduler: &TeammateManager) -> Result Result { - let tasks = scheduler.list_tasks().await.map_err(|e| e.to_string())?; +async fn exec_task_list(scheduler: &TeammateManager) -> Result { + let tasks = scheduler + .list_tasks() + .await + .map_err(|e| ToolCallError::from_message(e.to_string()))?; let output: Vec = tasks .iter() .map(|t| { @@ -710,10 +760,10 @@ async fn exec_task_list(scheduler: &TeammateManager) -> Result { }) }) .collect(); - serde_json::to_string_pretty(&output).map_err(|e| format!("Serialization error: {e}")) + serde_json::to_string_pretty(&output).map_err(|e| ToolCallError::from_message(format!("Serialization error: {e}"))) } -async fn exec_members(scheduler: &TeammateManager) -> Result { +async fn exec_members(scheduler: &TeammateManager) -> Result { let agents = scheduler.list_agents().await; let output: Vec = agents .iter() @@ -736,7 +786,7 @@ async fn exec_members(scheduler: &TeammateManager) -> Result { }) }) .collect(); - serde_json::to_string_pretty(&output).map_err(|e| format!("Serialization error: {e}")) + serde_json::to_string_pretty(&output).map_err(|e| ToolCallError::from_message(format!("Serialization error: {e}"))) } async fn exec_rename_agent( @@ -744,24 +794,27 @@ async fn exec_rename_agent( scheduler: &TeammateManager, service: &Weak, team_id: &str, -) -> Result { - let input: RenameAgentInput = serde_json::from_value(args.clone()).map_err(|e| format!("Invalid params: {e}"))?; +) -> Result { + let input: RenameAgentInput = + serde_json::from_value(args.clone()).map_err(|e| ToolCallError::from_message(format!("Invalid params: {e}")))?; - let resolved_slot = resolve_agent_target(scheduler, &input.slot_id).await?; + let resolved_slot = resolve_agent_target(scheduler, &input.slot_id) + .await + .map_err(ToolCallError::from_message)?; if let Some(svc) = service.upgrade() { let user_id = svc .get_session_user_id(team_id) .await - .ok_or_else(|| format!("No active session for team {team_id}"))?; + .ok_or_else(|| ToolCallError::from_message(format!("No active session for team {team_id}")))?; svc.rename_agent(&user_id, team_id, &resolved_slot, &input.new_name) .await - .map_err(|e| e.to_string())?; + .map_err(|e| ToolCallError::from_message(e.to_string()))?; } else { scheduler .rename_agent(&resolved_slot, &input.new_name) .await - .map_err(|e| e.to_string())?; + .map_err(|e| ToolCallError::from_message(e.to_string()))?; } Ok(format!("Agent '{}' renamed to '{}'", input.slot_id, input.new_name)) @@ -774,20 +827,23 @@ async fn exec_shutdown_agent( team_id: &str, caller_slot_id: &str, caller_role: TeammateRole, -) -> Result { +) -> Result { if caller_role != TeammateRole::Lead { - return Err("Only Lead can shut down agents".into()); + return Err(ToolCallError::from_message("Only Lead can shut down agents")); } - let input: ShutdownAgentInput = serde_json::from_value(args.clone()).map_err(|e| format!("Invalid params: {e}"))?; + let input: ShutdownAgentInput = + serde_json::from_value(args.clone()).map_err(|e| ToolCallError::from_message(format!("Invalid params: {e}")))?; - let target_slot_id = resolve_agent_target(scheduler, &input.slot_id).await?; + let target_slot_id = resolve_agent_target(scheduler, &input.slot_id) + .await + .map_err(ToolCallError::from_message)?; let service = service .upgrade() - .ok_or_else(|| "Team service not available; cannot wake shutdown target".to_string())?; + .ok_or_else(|| ToolCallError::from_message("Team service not available; cannot wake shutdown target"))?; service .require_active_team_run_for_team_work(team_id) .await - .map_err(|e| e.to_string())?; + .map_err(|e| ToolCallError::from_message(e.to_string()))?; let action = crate::scheduler::SchedulerAction::ShutdownAgent { slot_id: target_slot_id.clone(), @@ -796,12 +852,12 @@ async fn exec_shutdown_agent( scheduler .execute_action(caller_slot_id, &action) .await - .map_err(|e| e.to_string())?; + .map_err(|e| ToolCallError::from_message(e.to_string()))?; service .wake_agent_for_team_work(team_id, &target_slot_id, TeamWakeSource::McpShutdownRequest, None) .await - .map_err(|e| e.to_string())?; + .map_err(|e| ToolCallError::from_message(e.to_string()))?; Ok(format!("Shutdown request sent to agent '{}'", target_slot_id)) } @@ -893,7 +949,23 @@ async fn http_mcp_loop( .await { Ok(text) => json!({ "content": [{"type": "text", "text": text}] }), - Err(text) => json!({ "content": [{"type": "text", "text": text}], "isError": true }), + Err(err) => { + let mut result = json!({ + "content": [{"type": "text", "text": err.message}], + "isError": true, + }); + if err.domain_code.is_some() || err.details.is_some() { + let mut structured = json!({}); + if let Some(domain_code) = err.domain_code { + structured["domainCode"] = json!(domain_code); + } + if let Some(details) = err.details { + structured["details"] = details; + } + result["structuredContent"] = structured; + } + result + } } } _ => { @@ -940,7 +1012,7 @@ mod tests { let result = exec_spawn_agent(&args, &service, "team-1", "worker-1", TeammateRole::Teammate).await; let err = result.expect_err("non-Lead caller must be rejected"); assert!( - err.contains("Only Lead"), + err.message.contains("Only Lead"), "error must keep legacy 'Only Lead' phrasing, got {err:?}" ); } @@ -954,7 +1026,7 @@ mod tests { let result = exec_spawn_agent(&args, &service, "team-1", "lead-1", TeammateRole::Lead).await; let err = result.expect_err("malformed args must be rejected"); assert!( - err.contains("Invalid params"), + err.message.contains("Invalid params"), "must surface Invalid params for JSON deserialize failure, got {err:?}" ); } @@ -975,7 +1047,7 @@ mod tests { let result = exec_spawn_agent(&args, &service, "team-1", "lead-1", TeammateRole::Lead).await; let err = result.expect_err("dead Weak must not succeed"); assert!( - err.contains("Team service not available"), + err.message.contains("Team service not available"), "dead service weak must surface the unavailable message, got {err:?}" ); } @@ -987,9 +1059,10 @@ mod tests { let result = exec_spawn_agent(&args, &service, "team-1", "lead-1", TeammateRole::Lead).await; let err = result.expect_err("legacy backend alias must be rejected"); assert!( - err.contains("backend is no longer accepted"), + err.message.contains("backend is no longer accepted"), "expected explicit backend alias rejection, got {err:?}" ); + assert_eq!(err.domain_code, Some("TEAM_ASSISTANT_FIELD_UNSUPPORTED")); } #[tokio::test] @@ -999,9 +1072,10 @@ mod tests { let result = exec_spawn_agent(&args, &service, "team-1", "lead-1", TeammateRole::Lead).await; let err = result.expect_err("legacy agent_type alias must be rejected"); assert!( - err.contains("agent_type is no longer accepted"), + err.message.contains("agent_type is no longer accepted"), "expected explicit agent_type rejection, got {err:?}" ); + assert_eq!(err.domain_code, Some("TEAM_ASSISTANT_FIELD_UNSUPPORTED")); } #[tokio::test] @@ -1011,9 +1085,10 @@ mod tests { let result = exec_spawn_agent(&args, &service, "team-1", "lead-1", TeammateRole::Lead).await; let err = result.expect_err("assistant_id must now be required"); assert!( - err.contains("Missing required field: assistant_id"), + err.message.contains("Missing required field: assistant_id"), "expected assistant_id requirement, got {err:?}" ); + assert_eq!(err.domain_code, Some("TEAM_ASSISTANT_ID_REQUIRED")); } #[tokio::test] @@ -1023,9 +1098,10 @@ mod tests { let result = exec_list_models(&args, &service).await; let err = result.expect_err("legacy backend alias must be rejected"); assert!( - err.contains("backend is no longer accepted"), + err.message.contains("backend is no longer accepted"), "expected explicit backend rejection, got {err:?}" ); + assert_eq!(err.domain_code, Some("TEAM_ASSISTANT_FIELD_UNSUPPORTED")); } #[tokio::test] @@ -1035,9 +1111,10 @@ mod tests { let result = exec_list_models(&args, &service).await; let err = result.expect_err("legacy agent_type alias must be rejected"); assert!( - err.contains("agent_type is no longer accepted"), + err.message.contains("agent_type is no longer accepted"), "expected explicit agent_type rejection, got {err:?}" ); + assert_eq!(err.domain_code, Some("TEAM_ASSISTANT_FIELD_UNSUPPORTED")); } #[tokio::test] @@ -1046,8 +1123,9 @@ mod tests { let result = exec_list_assistants(&json!({}), &service).await; let err = result.expect_err("dead service should be surfaced"); assert!( - err.contains("Team service not available"), + err.message.contains("Team service not available"), "expected service unavailable error, got {err:?}" ); + assert_eq!(err.domain_code, Some("TEAM_SERVICE_UNAVAILABLE")); } } diff --git a/crates/aionui-team/src/routes.rs b/crates/aionui-team/src/routes.rs index 983d0e7b0..8dc9d9c27 100644 --- a/crates/aionui-team/src/routes.rs +++ b/crates/aionui-team/src/routes.rs @@ -17,7 +17,7 @@ use aionui_auth::CurrentUser; use aionui_common::ApiError; use aionui_db::DbError; -use crate::error::TeamError; +use crate::error::{TeamError, classify_public_error}; use crate::service::TeamSessionService; #[derive(Clone)] @@ -41,7 +41,13 @@ impl From for ApiError { TeamError::TeamNotFound(msg) => ApiError::NotFound(msg), TeamError::AgentNotFound(msg) => ApiError::NotFound(msg), TeamError::TaskNotFound(msg) => ApiError::NotFound(msg), - TeamError::InvalidRequest(msg) => ApiError::BadRequest(msg), + TeamError::InvalidRequest(msg) => { + if let Some(public) = classify_public_error(&msg) { + ApiError::coded(StatusCode::BAD_REQUEST, public.code, msg, public.details) + } else { + ApiError::BadRequest(msg) + } + } TeamError::SlotBusy(msg) => ApiError::BadRequest(format!("Team slot is busy: {msg}")), TeamError::LeaderOnly(msg) => ApiError::Forbidden(msg), TeamError::Forbidden(msg) => ApiError::Forbidden(msg), @@ -295,6 +301,7 @@ async fn stop_session( #[cfg(test)] mod tests { use super::*; + use serde_json::json; #[test] fn team_router_state_is_clone() { @@ -326,6 +333,40 @@ mod tests { assert!(matches!(err, ApiError::BadRequest(_))); } + #[test] + fn invalid_request_maps_missing_assistant_identity_to_coded_api_error() { + let err: ApiError = TeamError::InvalidRequest("spawn_agent.assistant_id is required".into()).into(); + assert_eq!(err.error_code(), "TEAM_ASSISTANT_ID_REQUIRED"); + assert_eq!(err.error_details(), Some(json!({ "field": "assistant_id" }))); + } + + #[test] + fn invalid_request_maps_unknown_assistant_to_coded_api_error() { + let err: ApiError = + TeamError::InvalidRequest("Preset assistant not found: bare:deadbeef".into()).into(); + assert_eq!(err.error_code(), "TEAM_ASSISTANT_NOT_FOUND"); + assert_eq!( + err.error_details(), + Some(json!({ + "assistant_id": "bare:deadbeef", + })) + ); + } + + #[test] + fn invalid_request_maps_legacy_identity_field_to_coded_api_error() { + let err: ApiError = + TeamError::InvalidRequest("backend is no longer accepted; use assistant_id".into()).into(); + assert_eq!(err.error_code(), "TEAM_ASSISTANT_FIELD_UNSUPPORTED"); + assert_eq!( + err.error_details(), + Some(json!({ + "field": "backend", + "required_field": "assistant_id", + })) + ); + } + #[test] fn leader_only_maps_to_forbidden() { let err: ApiError = TeamError::LeaderOnly("spawn_agent".into()).into(); From 3ed793f274e048820007f89a7fc55642d1fb0ac1 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 18 Jun 2026 20:14:51 +0800 Subject: [PATCH 114/135] fix(assistant): fall back to agent_type for empty bare backend Bare assistants for single-engine agents (e.g. Aion CLI) were generated with an empty agent_backend because their engine identity lives in agent_type, not the ACP-vendor backend column. An empty preset_agent_type made the frontend route aionrs assistants as ACP, dropping the top-level model and persisting a NULL model that later failed warmup with "Provider '' not found". Reconcile now falls back to agent_type when backend is empty. Also fix pre-existing test breakage left from the assistant-first migration: - add missing agent_status/team_selectable/deletable to the AssistantResponse camelCase rejection fixture - update channel default-settings e2e to expect the generated aionrs bare assistant binding instead of a null assistant - drop the obsolete agent.select persist integration test (direct agent selection is no longer supported; covered by the unknown-action unit test) - add missing last_check_error_details to the bare assistant test row --- crates/aionui-api-types/src/assistant.rs | 3 + crates/aionui-app/tests/channel_e2e.rs | 12 +++- crates/aionui-assistant/src/service.rs | 42 +++++++++++- .../tests/session_action_integration.rs | 65 ++----------------- 4 files changed, 58 insertions(+), 64 deletions(-) diff --git a/crates/aionui-api-types/src/assistant.rs b/crates/aionui-api-types/src/assistant.rs index 8a34bd2fa..a2fcb91fb 100644 --- a/crates/aionui-api-types/src/assistant.rs +++ b/crates/aionui-api-types/src/assistant.rs @@ -423,6 +423,9 @@ mod tests { "enabled": true, "sort_order": 7, // snake required field "preset_agent_type": "gemini", // snake required field + "agent_status": "available", // snake required field + "team_selectable": true, // snake required field + "deletable": true, // snake required field "presetAgentType": "claude", // legacy camel — must be ignored "sortOrder": 99, // legacy camel — must be ignored "lastUsedAt": 111_222, // legacy camel for optional field — must be ignored diff --git a/crates/aionui-app/tests/channel_e2e.rs b/crates/aionui-app/tests/channel_e2e.rs index f464fdac6..4ab1dd91a 100644 --- a/crates/aionui-app/tests/channel_e2e.rs +++ b/crates/aionui-app/tests/channel_e2e.rs @@ -317,7 +317,7 @@ async fn get_sessions_empty() { // =========================================================================== #[tokio::test] -async fn get_channel_settings_returns_empty_payload_by_default() { +async fn get_channel_settings_defaults_to_generated_aionrs_assistant() { let (mut app, services) = build_app().await; let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; @@ -328,7 +328,15 @@ async fn get_channel_settings_returns_empty_payload_by_default() { let json = body_json(resp).await; assert!(json["success"].as_bool().unwrap()); assert_eq!(json["data"]["platform"], "telegram"); - assert!(json["data"]["assistant"].is_null()); + // With no explicit binding the platform now falls back to the generated + // aionrs bare assistant (see channel "default to bare assistant bindings"); + // only the assistant_id is canonical, legacy fields are omitted. + let assistant_id = json["data"]["assistant"]["assistant_id"] + .as_str() + .expect("default channel assistant should be the generated aionrs bare assistant"); + assert!(assistant_id.starts_with("bare:"), "expected bare assistant id, got {assistant_id}"); + assert!(json["data"]["assistant"]["backend"].is_null()); + assert!(json["data"]["assistant"]["agent_type"].is_null()); assert!(json["data"]["default_model"].is_null()); } diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 5746c1181..75356c396 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -296,7 +296,17 @@ impl AssistantService { .resolve_definition_identity("generated", Some(&row.id), &assistant_key) .await?; let avatar_value = row.icon.as_deref().filter(|value| !value.trim().is_empty()); - let backend = row.backend.as_deref().unwrap_or(""); + // ACP agents expose their engine in `backend` (claude/gemini/…), but + // single-engine agents like Aion CLI carry it in `agent_type` and + // leave `backend` empty. Fall back to `agent_type` so the bare + // assistant always has a concrete `preset_agent_type` for the + // frontend to route on; an empty backend would otherwise drop the + // top-level model and fail warmup with "Provider '' not found". + let backend = row + .backend + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| row.agent_type.serde_name()); self.definition_repo .upsert(&UpsertAssistantDefinitionParams { @@ -2423,6 +2433,7 @@ mod tests { last_check_kind: Some(aionui_api_types::AgentSnapshotCheckKind::Manual), last_check_error_code: None, last_check_error_message: None, + last_check_error_details: None, last_check_guidance: None, last_check_latency_ms: Some(42), last_check_at: Some(1_750_000_000_000), @@ -2538,6 +2549,35 @@ mod tests { assert!(!bare.deletable); } + #[tokio::test] + async fn bootstrap_falls_back_to_agent_type_when_backend_is_empty() { + // Engines like Aion CLI carry their identity in `agent_type` and leave + // `backend` empty (it is an ACP-vendor label). The bare assistant must + // still expose a concrete `preset_agent_type` so the frontend can route + // it as an aionrs conversation; otherwise the top-level model is dropped + // and warmup fails with "Provider '' not found". + let mut agent_row = mk_agent_row( + "agent-aionrs", + "aionrs", + aionui_api_types::AgentManagementStatus::Available, + ); + agent_row.backend = None; + agent_row.agent_type = aionui_common::AgentType::Aionrs; + + let fx = fixture_with_options(FixtureOpts { + agent_rows: vec![agent_row], + ..Default::default() + }) + .await; + + let list = fx.service.list().await.unwrap(); + let bare = list + .iter() + .find(|assistant| assistant.id == "bare:agent-aionrs") + .unwrap(); + assert_eq!(bare.preset_agent_type, "aionrs"); + } + #[tokio::test] async fn bootstrap_places_new_bare_assistants_before_existing_assistants() { let fx = fixture_with_options(FixtureOpts { diff --git a/crates/aionui-channel/tests/session_action_integration.rs b/crates/aionui-channel/tests/session_action_integration.rs index 97e4843a1..5700a4965 100644 --- a/crates/aionui-channel/tests/session_action_integration.rs +++ b/crates/aionui-channel/tests/session_action_integration.rs @@ -414,67 +414,10 @@ async fn action_session_new_resets_existing() { assert_eq!(user_sessions.len(), 1); } -// ── ActionExecutor: agent.select persists agent_type (H-3 fix) ─── - -#[tokio::test] -async fn action_agent_select_persists() { - let (_, executor, pairing, repo) = setup().await; - - authorize_user(&pairing, "tg_42", "telegram").await; - - // Create a session (default agent is "gemini") - let msg1 = make_text_message("tg_42", "chat1", "Hello"); - executor.handle_incoming_message(&msg1).await.unwrap(); - - // Switch agent to "acp" - let select_msg = UnifiedIncomingMessage { - id: format!("msg_{}", now_ms()), - platform: PluginType::Telegram, - chat_id: "chat1".into(), - user: UnifiedUser { - id: "tg_42".into(), - username: None, - display_name: "Test User".into(), - avatar_url: None, - }, - content: UnifiedMessageContent { - content_type: MessageContentType::Action, - text: String::new(), - attachments: None, - }, - timestamp: now_ms(), - reply_to_message_id: None, - action: Some(UnifiedAction { - action: "agent.select".into(), - category: ActionCategory::System, - params: Some(std::collections::HashMap::from([("agentType".into(), "acp".into())])), - context: ActionContext { - platform: PluginType::Telegram, - user_id: "tg_42".into(), - chat_id: "chat1".into(), - message_id: None, - session_id: None, - }, - }), - raw: None, - }; - let r = executor.handle_incoming_message(&select_msg).await.unwrap(); - match r { - MessageResult::Action(resp) => { - let text = resp.text.unwrap(); - assert!(text.contains("acp")); - } - _ => panic!("Expected Action result"), - } - - // Verify the session's agent_type in DB - let all = repo.get_all_sessions().await.unwrap(); - let session = all - .iter() - .find(|s| s.chat_id.as_deref() == Some("chat1")) - .expect("session should exist"); - assert_eq!(session.agent_type, "acp"); -} +// NOTE: the former `action_agent_select_persists` test was removed. Direct +// `agent.select` channel actions are no longer supported under the +// assistant-first model — the handler now treats them as unknown actions +// (covered by `action::tests::agent_select_is_treated_as_unknown_action`). // ── ActionExecutor: session isolation across messages ─────────────── From 86e8d708fdba4bf4e43b5ff847ef650fa6d33ae8 Mon Sep 17 00:00:00 2001 From: zk <> Date: Mon, 22 Jun 2026 10:42:02 +0800 Subject: [PATCH 115/135] fix(cron): resolve assistant backend from snapshots --- crates/aionui-cron/src/executor.rs | 10 ++ crates/aionui-cron/src/service.rs | 98 +++++++++--- .../aionui-cron/tests/service_integration.rs | 143 +++++++++++++++++- 3 files changed, 225 insertions(+), 26 deletions(-) diff --git a/crates/aionui-cron/src/executor.rs b/crates/aionui-cron/src/executor.rs index 5485b91df..27ba3c5a8 100644 --- a/crates/aionui-cron/src/executor.rs +++ b/crates/aionui-cron/src/executor.rs @@ -193,6 +193,16 @@ impl JobExecutor { .map_err(CronError::Database) } + pub async fn get_assistant_snapshot( + &self, + conversation_id: &str, + ) -> Result, CronError> { + self.conversation_repo + .get_assistant_snapshot(conversation_id) + .await + .map_err(CronError::Database) + } + pub(crate) async fn resolve_job_workspace_raw(&self, job: &CronJob) -> Result { self.resolve_execution_workspace_raw(job, &job.conversation_id).await } diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index cdd538072..a1dd864f7 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -74,13 +74,14 @@ impl CronService { // ----------------------------------------------------------------------- pub async fn add_job(&self, req: CreateCronJobRequest) -> Result { - self.add_job_internal(req, None).await + self.add_job_internal(req, None, None).await } async fn add_job_internal( &self, req: CreateCronJobRequest, runtime_agent_type: Option, + assistant_backend_override: Option, ) -> Result { let schedule = schedule_from_dto(&req.schedule); validate_schedule(&schedule)?; @@ -97,8 +98,12 @@ impl CronService { let agent_config = match req.agent_config { Some(config) => Some( - self.build_cron_agent_config(&resolved_agent_type, sanitize_agent_config_dto(config)) - .await?, + self.build_cron_agent_config( + &resolved_agent_type, + sanitize_agent_config_dto(config), + assistant_backend_override.as_deref(), + ) + .await?, ), None => None, }; @@ -175,7 +180,7 @@ impl CronService { if let Some(config_dto) = &req.agent_config { let config_dto = sanitize_agent_config_dto(config_dto.clone()); validate_aionrs_agent_config(&job.agent_type, Some(&config_dto))?; - job.agent_config = Some(self.build_cron_agent_config(&job.agent_type, config_dto).await?); + job.agent_config = Some(self.build_cron_agent_config(&job.agent_type, config_dto, None).await?); } if let Some(title) = &req.conversation_title { job.conversation_title = Some(title.clone()); @@ -859,8 +864,23 @@ impl CronService { async fn build_agent_config_from_conversation( &self, row: &aionui_db::models::ConversationRow, - ) -> (String, Option) { + ) -> ( + String, + Option, + Option, + ) { let extra = serde_json::from_str::(&row.extra).unwrap_or_else(|_| serde_json::json!({})); + let assistant_snapshot = match self.executor.get_assistant_snapshot(&row.id).await { + Ok(snapshot) => snapshot, + Err(err) => { + warn!( + conversation_id = %row.id, + error = %err, + "Failed to load conversation assistant snapshot for cron agent config" + ); + None + } + }; // Both interactive `send_message` and the cron executor parse // `conversation.model` via the same helper. Keeping the cron-side // `agent_config.backend` derivation in sync with that parser @@ -869,11 +889,20 @@ impl CronService { let model_resolved = aionui_conversation::task_options::provider_model_from_conversation_row(row); let model = (!model_resolved.provider_id.is_empty()).then_some(&model_resolved); let preset_assistant_id = get_string(&extra, &["preset_assistant_id", "presetAssistantId"]); - let assistant_id = get_string(&extra, &["assistant_id", "assistantId"]).or(preset_assistant_id); - let assistant_backend = self + let extra_assistant_id = get_string(&extra, &["assistant_id", "assistantId"]).or(preset_assistant_id); + let snapshot_assistant_id = assistant_snapshot + .as_ref() + .map(|snapshot| snapshot.assistant_key.trim().to_owned()) + .filter(|value| !value.is_empty()); + let assistant_id = snapshot_assistant_id.or(extra_assistant_id); + let snapshot_backend = assistant_snapshot + .as_ref() + .map(|snapshot| snapshot.agent_backend.trim().to_owned()) + .filter(|value| !value.is_empty()); + let assistant_backend = snapshot_backend.clone().or(self .resolve_assistant_backend(assistant_id.as_deref()) .await - .unwrap_or(None); + .unwrap_or(None)); let backend = if row.r#type == "aionrs" { model @@ -901,7 +930,12 @@ impl CronService { .to_owned(); let agent_config = aionui_api_types::CronAgentConfigWriteDto { backend: Some(backend), - name: get_string(&extra, &["agent_name", "agentName"]).unwrap_or_else(|| row.name.clone()), + name: assistant_snapshot + .as_ref() + .map(|snapshot| snapshot.assistant_name.trim().to_owned()) + .filter(|value| !value.is_empty()) + .or_else(|| get_string(&extra, &["agent_name", "agentName"])) + .unwrap_or_else(|| row.name.clone()), cli_path: get_string(&extra, &["cli_path", "cliPath"]).or_else(|| { extra .get("gateway") @@ -911,25 +945,32 @@ impl CronService { }), assistant_id, mode: Some(full_auto_mode), - model_id: get_string(&extra, &["current_model_id", "currentModelId"]).or_else(|| { - model.and_then(|value| { - value - .use_model - .clone() - .or_else(|| (!value.model.is_empty()).then(|| value.model.clone())) + model_id: get_string(&extra, &["current_model_id", "currentModelId"]) + .or_else(|| { + model.and_then(|value| { + value + .use_model + .clone() + .or_else(|| (!value.model.is_empty()).then(|| value.model.clone())) + }) }) - }), + .or_else(|| { + assistant_snapshot + .as_ref() + .and_then(|snapshot| snapshot.resolved_model_id.clone()) + }), config_options: None, workspace: get_string(&extra, &["workspace"]), }; - (row.r#type.clone(), Some(agent_config)) + (row.r#type.clone(), Some(agent_config), snapshot_backend) } async fn build_cron_agent_config( &self, runtime_agent_type: &str, config: aionui_api_types::CronAgentConfigWriteDto, + assistant_backend_override: Option<&str>, ) -> Result { let backend = if runtime_agent_type == "aionrs" { config @@ -940,8 +981,11 @@ impl CronService { CronError::InvalidAgentConfig("aionrs cron jobs require agent_config.backend (provider_id)".into()) })? } else if let Some(assistant_id) = config.assistant_id.as_deref() { - self.resolve_assistant_backend(Some(assistant_id)) - .await? + assistant_backend_override + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or(self.resolve_assistant_backend(Some(assistant_id)).await?) .ok_or_else(|| { CronError::InvalidAgentConfig(format!( "assistant '{assistant_id}' could not resolve a runtime backend" @@ -1023,21 +1067,22 @@ impl aionui_conversation::response_middleware::ICronService for CronService { description: Some(params.schedule_description.clone()), }; - let (agent_type, conversation_title, agent_config) = + let (agent_type, conversation_title, agent_config, assistant_backend_override) = match self.executor.get_conversation_row(conversation_id).await { Ok(Some(row)) => { let title = Some(row.name.clone()); - let (agent_type, agent_config) = self.build_agent_config_from_conversation(&row).await; - (agent_type, title, agent_config) + let (agent_type, agent_config, assistant_backend_override) = + self.build_agent_config_from_conversation(&row).await; + (agent_type, title, agent_config, assistant_backend_override) } - Ok(None) => ("acp".to_owned(), None, None), + Ok(None) => ("acp".to_owned(), None, None, None), Err(err) => { warn!( conversation_id, error = %err, "Failed to load conversation context for cron create; falling back to defaults" ); - ("acp".to_owned(), None, None) + ("acp".to_owned(), None, None, None) } }; @@ -1054,7 +1099,10 @@ impl aionui_conversation::response_middleware::ICronService for CronService { agent_config, }; - match self.add_job_internal(req, Some(agent_type)).await { + match self + .add_job_internal(req, Some(agent_type), assistant_backend_override) + .await + { Ok(job) => { if let Err(err) = self .executor diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index 8745f3133..bf4ab0872 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -25,7 +25,8 @@ use aionui_db::{ IAssistantDefinitionRepository, IAssistantOverlayRepository, IConversationRepository, ICronRepository, MessageRowUpdate, MessageSearchRow, SortOrder, SqliteAcpSessionRepository, SqliteAgentMetadataRepository, SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, SqliteCronRepository, - UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, init_database_memory, models::MessageRow, + UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, init_database_memory, + models::{ConversationAssistantSnapshotRow, MessageRow}, }; use aionui_realtime::EventBroadcaster; @@ -103,6 +104,7 @@ struct StubConvRepo { messages: Mutex>, artifacts: Mutex>, rows: Mutex>, + assistant_snapshots: Mutex>, } impl StubConvRepo { @@ -111,6 +113,7 @@ impl StubConvRepo { messages: Mutex::new(Vec::new()), artifacts: Mutex::new(Vec::new()), rows: Mutex::new(HashMap::new()), + assistant_snapshots: Mutex::new(HashMap::new()), } } @@ -131,6 +134,13 @@ impl StubConvRepo { fn artifacts(&self) -> Vec { self.artifacts.lock().unwrap().clone() } + + fn insert_assistant_snapshot(&self, row: ConversationAssistantSnapshotRow) { + self.assistant_snapshots + .lock() + .unwrap() + .insert(row.conversation_id.clone(), row); + } } #[async_trait::async_trait] @@ -378,6 +388,49 @@ impl IConversationRepository for StubConvRepo { created_at: 1000, updated_at: 1000, } + } else if id == "conv_mode_missing_assistant_stale_backend" { + aionui_db::models::ConversationRow { + id: id.into(), + user_id: "u1".into(), + name: "Missing Assistant Stale Backend Chat".into(), + r#type: "acp".into(), + model: None, + status: Some("active".into()), + source: None, + channel_chat_id: None, + extra: serde_json::json!({ + "assistant_id": "missing-assistant", + "backend": "claude", + "agent_name": "Missing Assistant", + "workspace": ensure_named_workspace_path("aionui-cron-service-missing-assistant-stale-backend-workspace") + }) + .to_string(), + pinned: false, + pinned_at: None, + created_at: 1000, + updated_at: 1000, + } + } else if id == "conv_mode_assistant_snapshot" { + aionui_db::models::ConversationRow { + id: id.into(), + user_id: "u1".into(), + name: "Snapshot Assistant Chat".into(), + r#type: "acp".into(), + model: None, + status: Some("active".into()), + source: None, + channel_chat_id: None, + extra: serde_json::json!({ + "backend": "claude", + "agent_name": "Legacy Extra Assistant", + "workspace": ensure_named_workspace_path("aionui-cron-service-assistant-snapshot-workspace") + }) + .to_string(), + pinned: false, + pinned_at: None, + created_at: 1000, + updated_at: 1000, + } } else { aionui_db::models::ConversationRow { id: id.into(), @@ -399,6 +452,14 @@ impl IConversationRepository for StubConvRepo { rows.insert(id.to_owned(), row.clone()); Ok(Some(row)) } + + async fn get_assistant_snapshot( + &self, + conversation_id: &str, + ) -> Result, aionui_db::DbError> { + Ok(self.assistant_snapshots.lock().unwrap().get(conversation_id).cloned()) + } + async fn create(&self, row: &aionui_db::models::ConversationRow) -> Result<(), aionui_db::DbError> { self.rows.lock().unwrap().insert(row.id.clone(), row.clone()); Ok(()) @@ -1857,6 +1918,86 @@ async fn icron_service_create_job_prefers_assistant_backend_over_stale_extra_bac assert_eq!(config.model_id.as_deref(), Some("gpt-5.4")); } +#[tokio::test] +async fn icron_service_create_job_inherits_assistant_snapshot_identity() { + let (svc, _, _, conv_repo, definition_repo, _) = setup_with_assistant_repos().await; + seed_assistant_definition(&definition_repo, "asstdef_snapshot", "assistant-snapshot", "claude").await; + conv_repo.insert_assistant_snapshot(ConversationAssistantSnapshotRow { + conversation_id: "conv_mode_assistant_snapshot".into(), + assistant_definition_id: "asstdef_snapshot".into(), + assistant_key: "assistant-snapshot".into(), + assistant_source: "bare".into(), + assistant_name: "Snapshot Assistant".into(), + assistant_avatar_type: "emoji".into(), + assistant_avatar_value: Some("S".into()), + agent_backend: "codex".into(), + rules_content: String::new(), + default_model_mode: "default".into(), + resolved_model_id: Some("gpt-5.1".into()), + default_permission_mode: "default".into(), + resolved_permission_value: Some("ask".into()), + default_skills_mode: "default".into(), + resolved_skill_ids: "[]".into(), + resolved_disabled_builtin_skill_ids: "[]".into(), + default_mcps_mode: "default".into(), + resolved_mcp_ids: "[]".into(), + created_at: 1000, + updated_at: 1000, + }); + + use aionui_conversation::response_middleware::ICronService; + + let params = CronCreateParams { + name: "Snapshot Assistant Job".into(), + schedule: "0 */10 * * * *".into(), + schedule_description: "every 10 min".into(), + message: "do assistant work".into(), + }; + + let result = ICronService::create_job(&svc, "user_1", "conv_mode_assistant_snapshot", ¶ms).await; + assert!(result.success); + + let jobs = svc + .list_jobs(&ListCronJobsQuery { + conversation_id: Some("conv_mode_assistant_snapshot".into()), + }) + .await + .unwrap(); + assert_eq!(jobs.len(), 1); + + let config = jobs[0].agent_config.as_ref().expect("agent config should be copied"); + assert_eq!(config.assistant_id.as_deref(), Some("assistant-snapshot")); + assert_eq!(config.backend, "codex"); + assert_eq!(config.name, "Snapshot Assistant"); + assert_eq!(config.model_id.as_deref(), Some("gpt-5.1")); +} + +#[tokio::test] +async fn icron_service_create_job_rejects_stale_extra_backend_when_assistant_cannot_resolve() { + let (svc, _, _, _, _, _) = setup_with_assistant_repos().await; + + use aionui_conversation::response_middleware::ICronService; + + let params = CronCreateParams { + name: "Missing Assistant Job".into(), + schedule: "0 */10 * * * *".into(), + schedule_description: "every 10 min".into(), + message: "do assistant work".into(), + }; + + let result = ICronService::create_job(&svc, "user_1", "conv_mode_missing_assistant_stale_backend", ¶ms).await; + + assert!(!result.success); + assert!(result.message.contains("missing-assistant")); + let jobs = svc + .list_jobs(&ListCronJobsQuery { + conversation_id: Some("conv_mode_missing_assistant_stale_backend".into()), + }) + .await + .unwrap(); + assert!(jobs.is_empty()); +} + #[tokio::test] async fn icron_service_create_job_forces_full_auto_mode_for_generated_crons() { let (svc, _, _) = setup().await; From 019fc66097b8a932c3e11156328ec56ac7149f28 Mon Sep 17 00:00:00 2001 From: zk <> Date: Mon, 22 Jun 2026 13:47:23 +0800 Subject: [PATCH 116/135] refactor(agent): replace available/unavailable/needs_auth with online/offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the backend agent status model with the simplified frontend semantics: a probe only verifies an ACP handshake (reachability), not authorization, so the misleading "available"/"needs_auth" verdicts are dropped in favor of plain online/offline. - agent_discovery: rename AgentManagementStatus and AgentSnapshotCheckStatus variants Available/Unavailable/NeedsAuth -> Online/Offline (serde snake_case). - availability: simplify snapshot persistence — record_session_failure always yields offline; drop the success-recording path and auth detection. - registry: parse "online"/"offline"; derive management status as Offline -> Offline else Online. - turn_orchestrator: always report session_send_failed on send error; no success recording. - assistant/service + tests: migrate status values to online/offline. --- .../src/protocol/cli_detect.rs | 2 +- crates/aionui-ai-agent/src/registry.rs | 12 +- .../src/services/availability/mod.rs | 284 ++---------------- .../tests/agent_availability_integration.rs | 12 +- .../aionui-api-types/src/agent_discovery.rs | 16 +- crates/aionui-api-types/src/assistant.rs | 4 +- crates/aionui-assistant/src/service.rs | 30 +- .../aionui-conversation/src/service_test.rs | 4 - .../src/turn_orchestrator.rs | 91 ++---- 9 files changed, 85 insertions(+), 370 deletions(-) diff --git a/crates/aionui-ai-agent/src/protocol/cli_detect.rs b/crates/aionui-ai-agent/src/protocol/cli_detect.rs index c9d096549..6cf4f5756 100644 --- a/crates/aionui-ai-agent/src/protocol/cli_detect.rs +++ b/crates/aionui-ai-agent/src/protocol/cli_detect.rs @@ -80,7 +80,7 @@ mod tests { yolo_id: None, sort_order: 0, team_capable: false, - last_check_status: Some(AgentSnapshotCheckStatus::Available), + last_check_status: Some(AgentSnapshotCheckStatus::Online), last_check_kind: Some(AgentSnapshotCheckKind::Startup), last_check_error_code: None, last_check_error_message: None, diff --git a/crates/aionui-ai-agent/src/registry.rs b/crates/aionui-ai-agent/src/registry.rs index 5cc623a62..721365b08 100644 --- a/crates/aionui-ai-agent/src/registry.rs +++ b/crates/aionui-ai-agent/src/registry.rs @@ -366,7 +366,7 @@ impl AgentRegistry { /// keeps both uninstalled CLIs and rows that most recently failed /// ACP/session admission out of visible legacy catalog reads. fn is_visible(meta: &AgentMetadata) -> bool { - meta.enabled && matches!(derive_management_status(meta), AgentManagementStatus::Available) + meta.enabled && matches!(derive_management_status(meta), AgentManagementStatus::Online) } /// Extract and trim a command override, filtering out empty strings. @@ -589,9 +589,8 @@ fn parse_agent_source(raw: &str) -> Option { fn parse_last_check_status(raw: Option<&str>) -> Option { raw.and_then(|value| match value { - "available" => Some(AgentSnapshotCheckStatus::Available), - "unavailable" => Some(AgentSnapshotCheckStatus::Unavailable), - "needs_auth" => Some(AgentSnapshotCheckStatus::NeedsAuth), + "online" => Some(AgentSnapshotCheckStatus::Online), + "offline" => Some(AgentSnapshotCheckStatus::Offline), _ => { warn!(value, "agent_metadata: unknown last_check_status"); None @@ -618,9 +617,8 @@ fn derive_management_status(meta: &AgentMetadata) -> AgentManagementStatus { } match meta.last_check_status { - Some(AgentSnapshotCheckStatus::Unavailable) => AgentManagementStatus::Unavailable, - Some(AgentSnapshotCheckStatus::NeedsAuth) => AgentManagementStatus::NeedsAuth, - _ => AgentManagementStatus::Available, + Some(AgentSnapshotCheckStatus::Offline) => AgentManagementStatus::Offline, + _ => AgentManagementStatus::Online, } } diff --git a/crates/aionui-ai-agent/src/services/availability/mod.rs b/crates/aionui-ai-agent/src/services/availability/mod.rs index 7ae0642b5..b2b61bdec 100644 --- a/crates/aionui-ai-agent/src/services/availability/mod.rs +++ b/crates/aionui-ai-agent/src/services/availability/mod.rs @@ -28,7 +28,6 @@ const DEFAULT_SCHEDULED_INTERVAL: Duration = Duration::from_secs(300); #[async_trait::async_trait] pub trait AgentAvailabilityFeedbackPort: Send + Sync { async fn record_session_failure(&self, agent_id: &str, code: &str, message: &str) -> Result<(), AgentError>; - async fn record_session_success(&self, agent_id: &str) -> Result<(), AgentError>; } struct AvailabilitySnapshot { @@ -110,16 +109,8 @@ impl AgentAvailabilityService { pub async fn record_session_failure(&self, agent_id: &str, code: &str, message: &str) -> Result<(), AgentError> { let checked_at = now_ms(); - // Auth failures mean "installed + handshakes, but not logged in" — a - // distinct, actionable state, not "broken". Everything else stays - // unavailable. - let status = if code == "user_agent_auth_required" { - "needs_auth" - } else { - "unavailable" - }; let snapshot = AvailabilitySnapshot { - status, + status: "offline", kind: "session", error_code: Some(code.to_owned()), error_message: Some(message.to_owned()), @@ -129,19 +120,6 @@ impl AgentAvailabilityService { self.persist_snapshot(agent_id, &snapshot).await } - pub async fn record_session_success(&self, agent_id: &str) -> Result<(), AgentError> { - let checked_at = now_ms(); - let snapshot = AvailabilitySnapshot { - status: "available", - kind: "session", - error_code: None, - error_message: None, - latency_ms: 0, - checked_at, - }; - self.persist_snapshot(agent_id, &snapshot).await - } - pub async fn management_row_by_id(&self, id: &str) -> Option { self.registry .list_management_rows() @@ -172,22 +150,8 @@ impl AgentAvailabilityService { .map_err(|error| AgentError::internal(format!("repo.get: {error}")))? .ok_or_else(|| AgentError::not_found(format!("Agent '{id}' not found")))?; - // A probe only proves the handshake works; it never verifies auth. - // So a probe's `available` must not overwrite a known `needs_auth` - // (which only a real session can clear). Keep needs_auth, refresh ts. - let is_probe = matches!(snapshot.kind, "manual" | "scheduled" | "startup"); - let keep_needs_auth = is_probe - && snapshot.status == "available" - && existing.last_check_status.as_deref() == Some("needs_auth"); - - let effective_status = if keep_needs_auth { - "needs_auth" - } else { - snapshot.status - }; - let params = UpdateAgentAvailabilitySnapshotParams { - last_check_status: Some(effective_status), + last_check_status: Some(snapshot.status), last_check_kind: Some(snapshot.kind), last_check_error_code: snapshot.error_code.as_deref(), last_check_error_message: snapshot.error_message.as_deref(), @@ -197,12 +161,12 @@ impl AgentAvailabilityService { }), last_check_latency_ms: Some(snapshot.latency_ms), last_check_at: Some(snapshot.checked_at), - last_success_at: if effective_status == "available" { + last_success_at: if snapshot.status == "online" { Some(snapshot.checked_at) } else { existing.last_success_at }, - last_failure_at: if effective_status == "unavailable" { + last_failure_at: if snapshot.status == "offline" { Some(snapshot.checked_at) } else { existing.last_failure_at @@ -232,14 +196,14 @@ async fn run_probe( && let Some(tool) = ManagedAcpToolId::from_backend(backend) { match try_connect_builtin_managed_agent(meta, data_dir, tool).await { - TryConnectCustomAgentResponse::Success => (AgentSnapshotCheckStatus::Available, None, None), + TryConnectCustomAgentResponse::Success => (AgentSnapshotCheckStatus::Online, None, None), TryConnectCustomAgentResponse::FailCli { error } => ( - AgentSnapshotCheckStatus::Unavailable, + AgentSnapshotCheckStatus::Offline, Some("command_not_found".to_owned()), Some(error), ), TryConnectCustomAgentResponse::FailAcp { error } => ( - AgentSnapshotCheckStatus::Unavailable, + AgentSnapshotCheckStatus::Offline, Some("acp_init_failed".to_owned()), Some(error), ), @@ -251,14 +215,14 @@ async fn run_probe( .map(|entry| (entry.name.clone(), entry.value.clone())) .collect(); match custom_agent_probe::try_connect_custom_agent(command, &meta.args, &env, data_dir, None).await { - TryConnectCustomAgentResponse::Success => (AgentSnapshotCheckStatus::Available, None, None), + TryConnectCustomAgentResponse::Success => (AgentSnapshotCheckStatus::Online, None, None), TryConnectCustomAgentResponse::FailCli { error } => ( - AgentSnapshotCheckStatus::Unavailable, + AgentSnapshotCheckStatus::Offline, Some("command_not_found".to_owned()), Some(error), ), TryConnectCustomAgentResponse::FailAcp { error } => ( - AgentSnapshotCheckStatus::Unavailable, + AgentSnapshotCheckStatus::Offline, Some("acp_init_failed".to_owned()), Some(error), ), @@ -266,23 +230,22 @@ async fn run_probe( } else if let Some(backend) = meta.backend.as_deref() { let result = cli_detect::health_check(registry, backend).await; if result.available { - (AgentSnapshotCheckStatus::Available, None, None) + (AgentSnapshotCheckStatus::Online, None, None) } else { ( - AgentSnapshotCheckStatus::Unavailable, + AgentSnapshotCheckStatus::Offline, Some("health_check_failed".to_owned()), result.error, ) } } else { - (AgentSnapshotCheckStatus::Available, None, None) + (AgentSnapshotCheckStatus::Online, None, None) }; let latency_ms = start.elapsed().as_millis() as i64; let status = match status { - AgentSnapshotCheckStatus::Available => "available", - AgentSnapshotCheckStatus::Unavailable => "unavailable", - AgentSnapshotCheckStatus::NeedsAuth => "needs_auth", + AgentSnapshotCheckStatus::Online => "online", + AgentSnapshotCheckStatus::Offline => "offline", }; AvailabilitySnapshot { @@ -375,10 +338,6 @@ impl AgentAvailabilityFeedbackPort for AgentAvailabilityService { async fn record_session_failure(&self, agent_id: &str, code: &str, message: &str) -> Result<(), AgentError> { AgentAvailabilityService::record_session_failure(self, agent_id, code, message).await } - - async fn record_session_success(&self, agent_id: &str) -> Result<(), AgentError> { - AgentAvailabilityService::record_session_success(self, agent_id).await - } } #[cfg(test)] @@ -452,8 +411,8 @@ mod tests { .find(|item| item.id == "agent-session-failure") .unwrap(); - assert_eq!(row.status, AgentManagementStatus::Unavailable); - assert_eq!(row.last_check_status, Some(AgentSnapshotCheckStatus::Unavailable)); + assert_eq!(row.status, AgentManagementStatus::Offline); + assert_eq!(row.last_check_status, Some(AgentSnapshotCheckStatus::Offline)); assert_eq!(row.last_check_kind, Some(AgentSnapshotCheckKind::Session)); assert_eq!(row.last_check_error_code.as_deref(), Some("session_send_failed")); assert_eq!( @@ -468,143 +427,6 @@ mod tests { ); assert!(row.last_failure_at.is_some()); } - - #[tokio::test] - async fn record_session_failure_with_auth_required_persists_needs_auth() { - let db = init_database_memory().await.unwrap(); - let repo: Arc = Arc::new(SqliteAgentMetadataRepository::new(db.pool().clone())); - - repo.upsert(&UpsertAgentMetadataParams { - id: "auth-agent", - icon: None, - name: "Auth Agent", - name_i18n: None, - description: None, - description_i18n: None, - backend: Some("github"), - agent_type: "acp", - agent_source: "custom", - agent_source_info: Some(r#"{"binary_name":"gh"}"#), - enabled: true, - command: Some("gh"), - args: Some("[]"), - env: Some("[]"), - native_skills_dirs: None, - behavior_policy: None, - yolo_id: None, - agent_capabilities: None, - auth_methods: None, - config_options: None, - available_modes: None, - available_models: None, - available_commands: None, - sort_order: 100, - }) - .await - .unwrap(); - - let registry = AgentRegistry::new(repo); - registry.hydrate().await.unwrap(); - - let service = AgentAvailabilityService::new(registry.clone(), std::env::temp_dir()); - service - .record_session_failure("auth-agent", "user_agent_auth_required", "needs login") - .await - .unwrap(); - - let row = service - .list_management_rows() - .await - .into_iter() - .find(|item| item.id == "auth-agent") - .unwrap(); - - assert_eq!(row.status, AgentManagementStatus::NeedsAuth); - assert_eq!(row.last_check_status, Some(AgentSnapshotCheckStatus::NeedsAuth)); - assert_eq!(row.last_check_kind, Some(AgentSnapshotCheckKind::Session)); - assert_eq!(row.last_check_error_code.as_deref(), Some("user_agent_auth_required")); - assert_eq!(row.last_check_error_message.as_deref(), Some("needs login")); - } - - #[tokio::test] - async fn probe_available_does_not_clear_needs_auth() { - use super::AvailabilitySnapshot; - - let db = init_database_memory().await.unwrap(); - let repo: Arc = Arc::new(SqliteAgentMetadataRepository::new(db.pool().clone())); - - repo.upsert(&UpsertAgentMetadataParams { - id: "auth-agent-2", - icon: None, - name: "Auth Agent 2", - name_i18n: None, - description: None, - description_i18n: None, - backend: Some("github"), - agent_type: "acp", - agent_source: "custom", - agent_source_info: Some(r#"{"binary_name":"gh"}"#), - enabled: true, - command: Some("gh"), - args: Some("[]"), - env: Some("[]"), - native_skills_dirs: None, - behavior_policy: None, - yolo_id: None, - agent_capabilities: None, - auth_methods: None, - config_options: None, - available_modes: None, - available_models: None, - available_commands: None, - sort_order: 100, - }) - .await - .unwrap(); - - let registry = AgentRegistry::new(repo.clone()); - registry.hydrate().await.unwrap(); - - let service = AgentAvailabilityService::new(registry.clone(), std::env::temp_dir()); - - // First, record an auth failure - service - .record_session_failure("auth-agent-2", "user_agent_auth_required", "needs login") - .await - .unwrap(); - - // Verify it's set to needs_auth - let row = service - .list_management_rows() - .await - .into_iter() - .find(|item| item.id == "auth-agent-2") - .unwrap(); - assert_eq!(row.last_check_status, Some(AgentSnapshotCheckStatus::NeedsAuth)); - - // Now simulate a manual probe that succeeds - let probe = AvailabilitySnapshot { - status: "available", - kind: "manual", - error_code: None, - error_message: None, - latency_ms: 1, - checked_at: aionui_common::now_ms(), - }; - service.persist_snapshot("auth-agent-2", &probe).await.unwrap(); - - // Verify that needs_auth is preserved - let row = service - .list_management_rows() - .await - .into_iter() - .find(|item| item.id == "auth-agent-2") - .unwrap(); - assert_eq!(row.last_check_status, Some(AgentSnapshotCheckStatus::NeedsAuth)); - assert_eq!(row.status, AgentManagementStatus::NeedsAuth); - assert_eq!(row.last_check_kind, Some(AgentSnapshotCheckKind::Manual)); - } - #[tokio::test] async fn background_scheduler_persists_scheduled_snapshot() { let db = init_database_memory().await.unwrap(); @@ -672,76 +494,6 @@ mod tests { assert!(row.last_check_status.is_some()); assert!(row.last_check_at.is_some()); } - - #[tokio::test] - async fn record_session_success_clears_needs_auth() { - let db = init_database_memory().await.unwrap(); - let repo: Arc = Arc::new(SqliteAgentMetadataRepository::new(db.pool().clone())); - - repo.upsert(&UpsertAgentMetadataParams { - id: "auth-clear-agent", - icon: None, - name: "Auth Clear Agent", - name_i18n: None, - description: None, - description_i18n: None, - backend: Some("github"), - agent_type: "acp", - agent_source: "custom", - agent_source_info: Some(r#"{"binary_name":"gh"}"#), - enabled: true, - command: Some("gh"), - args: Some("[]"), - env: Some("[]"), - native_skills_dirs: None, - behavior_policy: None, - yolo_id: None, - agent_capabilities: None, - auth_methods: None, - config_options: None, - available_modes: None, - available_models: None, - available_commands: None, - sort_order: 100, - }) - .await - .unwrap(); - - let registry = AgentRegistry::new(repo); - registry.hydrate().await.unwrap(); - - let service = AgentAvailabilityService::new(registry.clone(), std::env::temp_dir()); - - // First, set needs_auth via session failure - service - .record_session_failure("auth-clear-agent", "user_agent_auth_required", "needs login") - .await - .unwrap(); - - let row = service - .list_management_rows() - .await - .into_iter() - .find(|item| item.id == "auth-clear-agent") - .unwrap(); - assert_eq!(row.status, AgentManagementStatus::NeedsAuth); - assert_eq!(row.last_check_status, Some(AgentSnapshotCheckStatus::NeedsAuth)); - - // Now record session success - service.record_session_success("auth-clear-agent").await.unwrap(); - - let row = service - .list_management_rows() - .await - .into_iter() - .find(|item| item.id == "auth-clear-agent") - .unwrap(); - assert_eq!(row.status, AgentManagementStatus::Available); - assert_eq!(row.last_check_status, Some(AgentSnapshotCheckStatus::Available)); - assert_eq!(row.last_check_kind, Some(AgentSnapshotCheckKind::Session)); - assert!(row.last_success_at.is_some()); - } - #[tokio::test] async fn managed_builtin_probe_checks_primary_binary_before_running_bridge_command() { let db = init_database_memory().await.unwrap(); @@ -803,7 +555,7 @@ mod tests { ) .await; - assert_eq!(snapshot.status, "unavailable"); + assert_eq!(snapshot.status, "offline"); assert_eq!(snapshot.error_code.as_deref(), Some("command_not_found")); assert!( snapshot diff --git a/crates/aionui-ai-agent/tests/agent_availability_integration.rs b/crates/aionui-ai-agent/tests/agent_availability_integration.rs index 0067a5604..333e31b6f 100644 --- a/crates/aionui-ai-agent/tests/agent_availability_integration.rs +++ b/crates/aionui-ai-agent/tests/agent_availability_integration.rs @@ -58,7 +58,7 @@ async fn management_rows_derive_missing_available_and_unavailable_statuses() { repo.update_availability_snapshot( "agent-unavailable", &UpdateAgentAvailabilitySnapshotParams { - last_check_status: Some("unavailable"), + last_check_status: Some("offline"), last_check_kind: Some("manual"), last_check_error_code: Some("auth_required"), last_check_error_message: Some("Login required"), @@ -75,7 +75,7 @@ async fn management_rows_derive_missing_available_and_unavailable_statuses() { repo.update_availability_snapshot( "agent-available", &UpdateAgentAvailabilitySnapshotParams { - last_check_status: Some("available"), + last_check_status: Some("online"), last_check_kind: Some("scheduled"), last_check_error_code: None, last_check_error_message: None, @@ -99,10 +99,10 @@ async fn management_rows_derive_missing_available_and_unavailable_statuses() { assert_eq!(missing.last_check_status, None); let unavailable = rows.iter().find(|row| row.id == "agent-unavailable").unwrap(); - assert_eq!(unavailable.status, AgentManagementStatus::Unavailable); + assert_eq!(unavailable.status, AgentManagementStatus::Offline); assert_eq!( unavailable.last_check_status, - Some(AgentSnapshotCheckStatus::Unavailable) + Some(AgentSnapshotCheckStatus::Offline) ); assert_eq!(unavailable.last_check_kind, Some(AgentSnapshotCheckKind::Manual)); assert_eq!(unavailable.last_check_error_code.as_deref(), Some("auth_required")); @@ -113,8 +113,8 @@ async fn management_rows_derive_missing_available_and_unavailable_statuses() { ); let available = rows.iter().find(|row| row.id == "agent-available").unwrap(); - assert_eq!(available.status, AgentManagementStatus::Available); - assert_eq!(available.last_check_status, Some(AgentSnapshotCheckStatus::Available)); + assert_eq!(available.status, AgentManagementStatus::Online); + assert_eq!(available.last_check_status, Some(AgentSnapshotCheckStatus::Online)); assert_eq!(available.last_check_kind, Some(AgentSnapshotCheckKind::Scheduled)); assert_eq!(available.last_check_latency_ms, Some(120)); } diff --git a/crates/aionui-api-types/src/agent_discovery.rs b/crates/aionui-api-types/src/agent_discovery.rs index 630e86340..c02d2fe4f 100644 --- a/crates/aionui-api-types/src/agent_discovery.rs +++ b/crates/aionui-api-types/src/agent_discovery.rs @@ -110,19 +110,15 @@ pub struct AgentHandshake { #[serde(rename_all = "snake_case")] pub enum AgentManagementStatus { Missing, - Available, - Unavailable, - #[serde(rename = "needs_auth")] - NeedsAuth, + Online, + Offline, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AgentSnapshotCheckStatus { - Available, - Unavailable, - #[serde(rename = "needs_auth")] - NeedsAuth, + Online, + Offline, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -402,8 +398,8 @@ mod tests { #[test] fn agent_management_status_serializes_snake_case() { - let value = serde_json::to_value(AgentManagementStatus::Unavailable).unwrap(); - assert_eq!(value, json!("unavailable")); + let value = serde_json::to_value(AgentManagementStatus::Offline).unwrap(); + assert_eq!(value, json!("offline")); } } diff --git a/crates/aionui-api-types/src/assistant.rs b/crates/aionui-api-types/src/assistant.rs index a2fcb91fb..589103890 100644 --- a/crates/aionui-api-types/src/assistant.rs +++ b/crates/aionui-api-types/src/assistant.rs @@ -346,7 +346,7 @@ mod tests { prompts_i18n: HashMap::new(), models: vec![], last_used_at: Some(1_234), - agent_status: AgentManagementStatus::Available, + agent_status: AgentManagementStatus::Online, agent_status_message: None, team_selectable: true, team_block_reason: None, @@ -423,7 +423,7 @@ mod tests { "enabled": true, "sort_order": 7, // snake required field "preset_agent_type": "gemini", // snake required field - "agent_status": "available", // snake required field + "agent_status": "online", // snake required field "team_selectable": true, // snake required field "deletable": true, // snake required field "presetAgentType": "claude", // legacy camel — must be ignored diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 75356c396..65839db3e 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -278,7 +278,7 @@ impl AssistantService { .filter(|row| { row.enabled && row.agent_type.supports_new_conversation() - && matches!(row.status, AgentManagementStatus::Available) + && matches!(row.status, AgentManagementStatus::Online) }) .collect(); let missing_generated_count = generated_rows @@ -1903,7 +1903,7 @@ fn assistant_projection_for_definition( Some(row) if matches!(row.status, AgentManagementStatus::Missing) => { Some("This assistant's agent is not installed.".to_string()) } - Some(row) if matches!(row.status, AgentManagementStatus::Unavailable) => Some( + Some(row) if matches!(row.status, AgentManagementStatus::Offline) => Some( row.last_check_error_message .clone() .or_else(|| row.last_check_guidance.clone()) @@ -1919,7 +1919,7 @@ fn assistant_projection_for_definition( agent_status, agent_status_message, team_selectable: enabled - && agent_row.is_some_and(|row| matches!(row.status, AgentManagementStatus::Available) && row.team_capable), + && agent_row.is_some_and(|row| matches!(row.status, AgentManagementStatus::Online) && row.team_capable), team_block_reason, deletable: matches!(source, AssistantSource::User), } @@ -2429,7 +2429,7 @@ mod tests { sort_order: 3100, team_capable: true, status, - last_check_status: Some(aionui_api_types::AgentSnapshotCheckStatus::Available), + last_check_status: Some(aionui_api_types::AgentSnapshotCheckStatus::Online), last_check_kind: Some(aionui_api_types::AgentSnapshotCheckKind::Manual), last_check_error_code: None, last_check_error_message: None, @@ -2531,7 +2531,7 @@ mod tests { agent_rows: vec![mk_agent_row( "agent-claude", "claude", - aionui_api_types::AgentManagementStatus::Available, + aionui_api_types::AgentManagementStatus::Online, )], ..Default::default() }) @@ -2544,7 +2544,7 @@ mod tests { .unwrap(); assert_eq!(bare.source, AssistantSource::Bare); assert_eq!(bare.preset_agent_type, "claude"); - assert_eq!(bare.agent_status, aionui_api_types::AgentManagementStatus::Available); + assert_eq!(bare.agent_status, aionui_api_types::AgentManagementStatus::Online); assert!(bare.team_selectable); assert!(!bare.deletable); } @@ -2559,7 +2559,7 @@ mod tests { let mut agent_row = mk_agent_row( "agent-aionrs", "aionrs", - aionui_api_types::AgentManagementStatus::Available, + aionui_api_types::AgentManagementStatus::Online, ); agent_row.backend = None; agent_row.agent_type = aionui_common::AgentType::Aionrs; @@ -2586,12 +2586,12 @@ mod tests { mk_agent_row( "agent-claude", "claude", - aionui_api_types::AgentManagementStatus::Available, + aionui_api_types::AgentManagementStatus::Online, ), mk_agent_row( "agent-codex", "codex", - aionui_api_types::AgentManagementStatus::Available, + aionui_api_types::AgentManagementStatus::Online, ), ], ..Default::default() @@ -2621,7 +2621,7 @@ mod tests { agent_rows: vec![mk_agent_row( "agent-claude", "claude", - aionui_api_types::AgentManagementStatus::Available, + aionui_api_types::AgentManagementStatus::Online, )], ..Default::default() }) @@ -2892,7 +2892,7 @@ mod tests { agent_rows: vec![mk_agent_row( "agent-claude", "claude", - aionui_api_types::AgentManagementStatus::Available, + aionui_api_types::AgentManagementStatus::Online, )], ..Default::default() }) @@ -3487,7 +3487,7 @@ mod tests { agent_rows: vec![mk_agent_row( "agent-claude", "claude", - aionui_api_types::AgentManagementStatus::Available, + aionui_api_types::AgentManagementStatus::Online, )], ..Default::default() }) @@ -3506,7 +3506,7 @@ mod tests { agent_rows: vec![mk_agent_row( "agent-claude", "claude", - aionui_api_types::AgentManagementStatus::Available, + aionui_api_types::AgentManagementStatus::Online, )], ..Default::default() }) @@ -3521,7 +3521,7 @@ mod tests { agent_rows: vec![mk_agent_row( "agent-claude", "claude", - aionui_api_types::AgentManagementStatus::Available, + aionui_api_types::AgentManagementStatus::Online, )], ..Default::default() }) @@ -3540,7 +3540,7 @@ mod tests { agent_rows: vec![mk_agent_row( "agent-claude", "claude", - aionui_api_types::AgentManagementStatus::Available, + aionui_api_types::AgentManagementStatus::Online, )], ..Default::default() }) diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 5ff0e1432..f20f1e26b 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -187,10 +187,6 @@ impl AgentAvailabilityFeedbackPort for RecordingAvailabilityFeedback { }); Ok(()) } - - async fn record_session_success(&self, _agent_id: &str) -> Result<(), AgentError> { - Ok(()) - } } // ── Mock Repository ──────────────────────────────────────────────── diff --git a/crates/aionui-conversation/src/turn_orchestrator.rs b/crates/aionui-conversation/src/turn_orchestrator.rs index 536cb7f74..3fd3ab345 100644 --- a/crates/aionui-conversation/src/turn_orchestrator.rs +++ b/crates/aionui-conversation/src/turn_orchestrator.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use aionui_ai_agent::types::{BuildTaskOptions, SendMessageData}; use aionui_ai_agent::{AgentSendError, AgentSessionKind, IWorkerTaskManager}; -use aionui_api_types::AgentErrorCode; use aionui_common::{ConversationStatus, ErrorChain, now_ms}; use aionui_db::models::ConversationRow; use tokio::sync::oneshot; @@ -169,52 +168,42 @@ impl ConversationTurnOrchestrator { let (send_error_tx, send_error_rx) = oneshot::channel(); tokio::spawn(async move { - match send_agent.send_message(current_send).await { - Ok(()) => { - record_agent_session_success(&feedback_service, feedback_agent_id.as_deref()).await; - } - Err(e) => { - let failure_message = availability_failure_message(&e); - let code = if e.code() == Some(AgentErrorCode::UserAgentAuthRequired) { - "user_agent_auth_required" - } else { - "session_send_failed" - }; - record_agent_session_failure( - &feedback_service, - feedback_agent_id.as_deref(), - code, - &failure_message, - ) - .await; - let task_status = send_agent.status(); - let agent_type = send_agent.agent_type(); - error!( + if let Err(e) = send_agent.send_message(current_send).await { + let failure_message = availability_failure_message(&e); + record_agent_session_failure( + &feedback_service, + feedback_agent_id.as_deref(), + "session_send_failed", + &failure_message, + ) + .await; + let task_status = send_agent.status(); + let agent_type = send_agent.agent_type(); + error!( + conversation_id = %conv_id_send, + turn_id = %turn_id_for_send, + ?agent_type, + ?task_status, + error = %ErrorChain(&e), + "Agent send_message failed" + ); + if task_status == Some(ConversationStatus::Finished) { + debug!( + conversation_id = %conv_id_send, + turn_id = %turn_id_for_send, + ?agent_type, + "Agent send_message failure already published runtime terminal; skipping fallback stream error" + ); + } else { + warn!( conversation_id = %conv_id_send, turn_id = %turn_id_for_send, ?agent_type, - ?task_status, - error = %ErrorChain(&e), - "Agent send_message failed" + code = ?e.code(), + ownership = ?e.ownership(), + "Agent send_message returned error without runtime terminal; injecting fallback stream error" ); - if task_status == Some(ConversationStatus::Finished) { - debug!( - conversation_id = %conv_id_send, - turn_id = %turn_id_for_send, - ?agent_type, - "Agent send_message failure already published runtime terminal; skipping fallback stream error" - ); - } else { - warn!( - conversation_id = %conv_id_send, - turn_id = %turn_id_for_send, - ?agent_type, - code = ?e.code(), - ownership = ?e.ownership(), - "Agent send_message returned error without runtime terminal; injecting fallback stream error" - ); - let _ = send_error_tx.send(e); - } + let _ = send_error_tx.send(e); } } }); @@ -316,19 +305,3 @@ async fn record_agent_session_failure( ); } } - -async fn record_agent_session_success(service: &ConversationService, agent_id: Option<&str>) { - let Some(agent_id) = agent_id else { - return; - }; - let Some(feedback) = service.agent_availability_feedback() else { - return; - }; - if let Err(error) = feedback.record_session_success(agent_id).await { - warn!( - agent_id, - error = %ErrorChain(&error), - "Failed to record agent availability session success" - ); - } -} From d2793716418ec44b91e39d4605ad333eef99a118 Mon Sep 17 00:00:00 2001 From: zk <> Date: Mon, 22 Jun 2026 15:24:40 +0800 Subject: [PATCH 117/135] feat(agent): probe session/new to detect auth and gate aionrs on providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deepen the agent health probe from `initialize` to `session/new` so it reflects real usability, not just protocol reachability — `initialize` returns authMethods even for authorized agents and cannot tell apart "reachable but not signed in". - custom_agent_probe: after `initialize`, open a throwaway `session/new` (no prompt); classify the outcome as Ok / Auth (ACP auth_required, JSON-RPC -32000) / Fail. Applies to both the custom and builtin-managed probe paths. - api-types: add `TryConnectCustomAgentResponse::FailAuth` (tag `fail_auth`). - availability: map FailAuth → offline + `auth_required` code; gate aionrs (built-in agent, no external CLI) availability on having at least one enabled model provider, mirroring AssistantService::resolve_default_agent_type — offline + `no_provider` otherwise. - custom: accept test-on-save when the agent is reachable but auth_required (a valid agent the user just hasn't logged into yet). - registry: add guidance for auth_required and no_provider error codes. - The background scheduler shares run_probe, so periodic checks reflect the same session/new-based status. --- .../src/protocol/custom_agent_probe.rs | 103 +++++++++---- crates/aionui-ai-agent/src/registry.rs | 6 + crates/aionui-ai-agent/src/services/agent.rs | 4 +- .../src/services/availability/mod.rs | 139 ++++++++++++++++-- crates/aionui-ai-agent/src/services/custom.rs | 7 + crates/aionui-api-types/src/acp.rs | 24 ++- 6 files changed, 236 insertions(+), 47 deletions(-) diff --git a/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs b/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs index b869e78ef..fcd6bcab4 100644 --- a/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs +++ b/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs @@ -24,6 +24,9 @@ use tracing::{debug, warn}; use crate::capability::cli_process::CliAgentProcess; use crate::protocol::acp::AcpProtocol; +use crate::protocol::error::AcpError; + +use agent_client_protocol::schema::NewSessionRequest; /// Step 2 overall timeout. Belt-and-suspenders: `AcpProtocol::connect` /// already caps the initialize RPC at 30 s, but a CLI that hangs @@ -67,10 +70,9 @@ pub async fn try_connect_custom_agent( }; let outcome = match tokio::time::timeout(STEP2_TIMEOUT, run_handshake(&proc)).await { - Ok(Ok(())) => TryConnectCustomAgentResponse::Success, - Ok(Err(msg)) => TryConnectCustomAgentResponse::FailAcp { error: msg }, + Ok(outcome) => outcome.into_response(), Err(_) => TryConnectCustomAgentResponse::FailAcp { - error: format!("ACP initialize did not complete within {}s", STEP2_TIMEOUT.as_secs()), + error: format!("ACP handshake did not complete within {}s", STEP2_TIMEOUT.as_secs()), }, }; @@ -144,25 +146,54 @@ impl Drop for ProbeProcessGuard<'_> { } } -pub(crate) async fn acp_initialize_command_spec(spec: CommandSpec, data_dir: &Path) -> Result<(), String> { - let proc = CliAgentProcess::spawn_for_sdk(spec, data_dir) - .await - .map_err(|e| format!("spawn failed: {e}"))?; +/// Probe a pre-built [`CommandSpec`] (used by the builtin managed-agent path). +/// +/// Runs the same Step 2 handshake as [`try_connect_custom_agent`] — +/// `initialize` followed by `session/new` — so auth-gated builtin agents +/// (e.g. gemini logged out) surface as [`TryConnectCustomAgentResponse::FailAuth`] +/// rather than appearing online. +pub(crate) async fn acp_probe_command_spec(spec: CommandSpec, data_dir: &Path) -> TryConnectCustomAgentResponse { + let proc = match CliAgentProcess::spawn_for_sdk(spec, data_dir).await { + Ok(proc) => proc, + Err(e) => return TryConnectCustomAgentResponse::FailAcp { error: format!("spawn failed: {e}") }, + }; // From here on, the process tree is reaped on every exit path, including // cancellation when an outer timeout drops this future. let _guard = ProbeProcessGuard { proc: &proc }; - run_handshake(&proc).await + run_handshake(&proc).await.into_response() } -async fn run_handshake(proc: &CliAgentProcess) -> Result<(), String> { - let (stdin, stdout) = proc - .take_stdio() - .await - .ok_or_else(|| "stdio not available after spawn_for_sdk".to_string())?; +/// Result of the Step 2 probe (`initialize` + `session/new`). +/// +/// The probe reaches `session/new` so it can tell "reachable but not +/// authorized" (`Auth`) apart from other ACP failures (`Fail`) — `initialize` +/// alone returns `authMethods` even for already-authorized agents and cannot +/// make this distinction. +enum ProbeOutcome { + Ok, + Auth(String), + Fail(String), +} + +impl ProbeOutcome { + fn into_response(self) -> TryConnectCustomAgentResponse { + match self { + ProbeOutcome::Ok => TryConnectCustomAgentResponse::Success, + ProbeOutcome::Auth(error) => TryConnectCustomAgentResponse::FailAuth { error }, + ProbeOutcome::Fail(error) => TryConnectCustomAgentResponse::FailAcp { error }, + } + } +} - // Throwaway channels — we only care about init handshake succeeding. +async fn run_handshake(proc: &CliAgentProcess) -> ProbeOutcome { + let Some((stdin, stdout)) = proc.take_stdio().await else { + return ProbeOutcome::Fail("stdio not available after spawn_for_sdk".to_string()); + }; + + // Throwaway channels — a probe session never sends a prompt, so no events, + // permission requests, or notifications are consumed. let (event_tx, _event_rx) = broadcast::channel(16); let (permission_tx, _permission_rx) = mpsc::channel(4); let (notification_tx, _notification_rx) = mpsc::channel(4); @@ -173,16 +204,12 @@ async fn run_handshake(proc: &CliAgentProcess) -> Result<(), String> { // `AcpProtocol::connect` call would block on its internal 30 s // timeout waiting for an `initialize` reply that will never arrive. let connect = AcpProtocol::connect(stdin, stdout, event_tx, permission_tx, notification_tx); - tokio::select! { + let protocol = tokio::select! { biased; - res = connect => { - let protocol = res.map_err(|e| format!("ACP initialize failed: {e}"))?; - // We only care that the handshake succeeded. Drop `protocol` so - // its shutdown oneshot fires before the outer cleanup path (or the - // drop guard for timeout-cancelled callers) reaps the process tree. - drop(protocol); - Ok(()) - } + res = connect => match res { + Ok(protocol) => protocol, + Err(e) => return ProbeOutcome::Fail(format!("ACP initialize failed: {e}")), + }, exit = proc.wait_for_exit() => { let stderr = proc.take_stderr().await; let stderr = stderr.trim(); @@ -190,13 +217,31 @@ async fn run_handshake(proc: &CliAgentProcess) -> Result<(), String> { Some(s) => format!("{s}"), None => "unknown".to_string(), }; - if stderr.is_empty() { - Err(format!("CLI exited before ACP initialize completed (status={status})")) + return if stderr.is_empty() { + ProbeOutcome::Fail(format!("CLI exited before ACP initialize completed (status={status})")) } else { - Err(format!("CLI exited before ACP initialize completed (status={status}): {stderr}")) - } + ProbeOutcome::Fail(format!("CLI exited before ACP initialize completed (status={status}): {stderr}")) + }; } - } + }; + + // `initialize` only proves the agent speaks ACP, not that it is usable. + // Open a real session (no prompt) so an auth-gated agent surfaces its + // `auth_required` error here instead of silently appearing "online". + let outcome = match protocol.new_session(NewSessionRequest::new(std::env::temp_dir())).await { + Ok(_) => ProbeOutcome::Ok, + Err(AcpError::AuthRequired) => { + ProbeOutcome::Auth("Agent reachable but requires login/authorization".to_string()) + } + Err(e) => ProbeOutcome::Fail(format!("ACP session/new failed: {e}")), + }; + + // Drop `protocol` so its shutdown oneshot fires before the outer cleanup + // path (or the drop guard for timeout-cancelled callers) reaps the process + // tree. The probe session is throwaway; the process-group kill in the + // caller tears down the session along with the CLI. + drop(protocol); + outcome } #[cfg(test)] @@ -255,7 +300,7 @@ mod tests { let _ = aionui_runtime::agent_process_env().await; let tmp = std::env::temp_dir(); - let result = tokio::time::timeout(Duration::from_secs(2), acp_initialize_command_spec(spec, &tmp)).await; + let result = tokio::time::timeout(Duration::from_secs(2), acp_probe_command_spec(spec, &tmp)).await; assert!( result.is_err(), "handshake should not complete; outer timeout must fire" diff --git a/crates/aionui-ai-agent/src/registry.rs b/crates/aionui-ai-agent/src/registry.rs index 721365b08..8312a7a77 100644 --- a/crates/aionui-ai-agent/src/registry.rs +++ b/crates/aionui-ai-agent/src/registry.rs @@ -765,12 +765,18 @@ pub(crate) fn guidance_for_snapshot_error_code(error_code: &str) -> &'static str "acp_init_failed" => { "The CLI was found, but ACP initialization failed. Complete sign-in or setup in the CLI, then run Test Connection again." } + "auth_required" => { + "The agent is reachable but requires sign-in. Log in via the CLI (or add the required API key under Environment Variables), then run Test Connection again." + } "health_check_failed" => { "Open the CLI once to finish any first-run setup or sign-in flow, then run Test Connection again." } "session_send_failed" => { "Fix the provider credentials or network issue that caused the last session failure, then start a new conversation." } + "no_provider" => { + "Add and enable a model provider in Settings, then run Test Connection again." + } _ => "", } } diff --git a/crates/aionui-ai-agent/src/services/agent.rs b/crates/aionui-ai-agent/src/services/agent.rs index 9d12a64e8..3245e30d9 100644 --- a/crates/aionui-ai-agent/src/services/agent.rs +++ b/crates/aionui-ai-agent/src/services/agent.rs @@ -42,8 +42,8 @@ impl AgentService { encryption_key: [u8; 32], data_dir: PathBuf, ) -> Arc { - let provider_health = ProviderHealthCheckService::new(provider_repo, encryption_key, data_dir.clone()); - let availability = AgentAvailabilityService::new(registry.clone(), data_dir.clone()); + let provider_health = ProviderHealthCheckService::new(provider_repo.clone(), encryption_key, data_dir.clone()); + let availability = AgentAvailabilityService::new(registry.clone(), provider_repo, data_dir.clone()); Arc::new(Self { registry, broadcaster, diff --git a/crates/aionui-ai-agent/src/services/availability/mod.rs b/crates/aionui-ai-agent/src/services/availability/mod.rs index b2b61bdec..9c2aa2ad5 100644 --- a/crates/aionui-ai-agent/src/services/availability/mod.rs +++ b/crates/aionui-ai-agent/src/services/availability/mod.rs @@ -11,8 +11,8 @@ use aionui_api_types::{ TryConnectCustomAgentResponse, }; use aionui_common::now_ms; -use aionui_common::{CommandSpec, EnvVar}; -use aionui_db::UpdateAgentAvailabilitySnapshotParams; +use aionui_common::{AgentType, CommandSpec, EnvVar}; +use aionui_db::{IProviderRepository, UpdateAgentAvailabilitySnapshotParams}; use aionui_runtime::{ ManagedAcpToolId, ensure_managed_acp_tool_with_reporter, ensure_node_runtime_with_reporter, resolve_command_path, }; @@ -43,16 +43,24 @@ struct AvailabilitySnapshot { pub struct AgentAvailabilityService { registry: Arc, data_dir: PathBuf, + // Used to decide aionrs (built-in, no external CLI) availability: it is + // usable only when at least one model provider is configured & enabled. + provider_repo: Arc, scheduler_started: Arc, startup_delay: Duration, scheduled_interval: Duration, } impl AgentAvailabilityService { - pub fn new(registry: Arc, data_dir: PathBuf) -> Self { + pub fn new( + registry: Arc, + provider_repo: Arc, + data_dir: PathBuf, + ) -> Self { Self { registry, data_dir, + provider_repo, scheduler_started: Arc::new(AtomicBool::new(false)), startup_delay: DEFAULT_STARTUP_DELAY, scheduled_interval: DEFAULT_SCHEDULED_INTERVAL, @@ -100,7 +108,14 @@ impl AgentAvailabilityService { .ok_or_else(|| AgentError::not_found(format!("Agent '{id}' not found"))); } - let snapshot = run_probe(&self.registry, &meta, &self.data_dir, AgentSnapshotCheckKind::Manual).await; + let snapshot = run_probe( + &self.registry, + &self.provider_repo, + &meta, + &self.data_dir, + AgentSnapshotCheckKind::Manual, + ) + .await; self.persist_snapshot(id, &snapshot).await?; self.management_row_by_id(id) .await @@ -135,7 +150,14 @@ impl AgentAvailabilityService { .into_iter() .filter(|item| item.enabled && item.available && item.agent_type.supports_new_conversation()) { - let snapshot = run_probe(&self.registry, &meta, &self.data_dir, AgentSnapshotCheckKind::Scheduled).await; + let snapshot = run_probe( + &self.registry, + &self.provider_repo, + &meta, + &self.data_dir, + AgentSnapshotCheckKind::Scheduled, + ) + .await; self.persist_snapshot(&meta.id, &snapshot).await?; } Ok(()) @@ -184,6 +206,7 @@ impl AgentAvailabilityService { async fn run_probe( registry: &Arc, + provider_repo: &Arc, meta: &AgentMetadata, data_dir: &std::path::Path, kind: AgentSnapshotCheckKind, @@ -207,6 +230,13 @@ async fn run_probe( Some("acp_init_failed".to_owned()), Some(error), ), + // Reachable but not authorized: still offline (unusable), but a + // dedicated code lets the UI guide the user to log in. + TryConnectCustomAgentResponse::FailAuth { error } => ( + AgentSnapshotCheckStatus::Offline, + Some("auth_required".to_owned()), + Some(error), + ), } } else if let Some(command) = meta.command.as_deref() { let env: HashMap = meta @@ -226,6 +256,13 @@ async fn run_probe( Some("acp_init_failed".to_owned()), Some(error), ), + // Reachable but not authorized: still offline (unusable), but a + // dedicated code lets the UI guide the user to log in. + TryConnectCustomAgentResponse::FailAuth { error } => ( + AgentSnapshotCheckStatus::Offline, + Some("auth_required".to_owned()), + Some(error), + ), } } else if let Some(backend) = meta.backend.as_deref() { let result = cli_detect::health_check(registry, backend).await; @@ -238,6 +275,12 @@ async fn run_probe( result.error, ) } + } else if meta.agent_type == AgentType::Aionrs { + // aionrs is the built-in Rust agent: there is no external CLI to probe, + // so its usability hinges entirely on having a configured model. It is + // online only when at least one model provider is enabled — otherwise + // it cannot run a single turn. + probe_aionrs_provider_readiness(provider_repo).await } else { (AgentSnapshotCheckStatus::Online, None, None) }; @@ -263,6 +306,31 @@ async fn run_probe( } } +/// Readiness check for the built-in aionrs agent. +/// +/// aionrs has no external CLI; it runs models through configured providers. +/// Mirrors `AssistantService::resolve_default_agent_type`, which treats aionrs +/// as usable exactly when at least one provider is enabled. With no enabled +/// provider it cannot complete a turn, so we report it offline with a +/// `no_provider` code the UI maps to "configure a model" guidance. +async fn probe_aionrs_provider_readiness( + provider_repo: &Arc, +) -> (AgentSnapshotCheckStatus, Option, Option) { + match provider_repo.list().await { + Ok(providers) if providers.iter().any(|p| p.enabled) => (AgentSnapshotCheckStatus::Online, None, None), + Ok(_) => ( + AgentSnapshotCheckStatus::Offline, + Some("no_provider".to_owned()), + Some("No model provider is configured. Add and enable a provider to use the built-in agent.".to_owned()), + ), + Err(e) => ( + AgentSnapshotCheckStatus::Offline, + Some("no_provider".to_owned()), + Some(format!("Failed to read model providers: {e}")), + ), + } +} + async fn try_connect_builtin_managed_agent( meta: &AgentMetadata, data_dir: &std::path::Path, @@ -321,14 +389,13 @@ async fn try_connect_builtin_managed_agent( match tokio::time::timeout( Duration::from_secs(35), - custom_agent_probe::acp_initialize_command_spec(spec, data_dir), + custom_agent_probe::acp_probe_command_spec(spec, data_dir), ) .await { - Ok(Ok(())) => TryConnectCustomAgentResponse::Success, - Ok(Err(error)) => TryConnectCustomAgentResponse::FailAcp { error }, + Ok(response) => response, Err(_) => TryConnectCustomAgentResponse::FailAcp { - error: "ACP initialize did not complete within 35s".to_owned(), + error: "ACP handshake did not complete within 35s".to_owned(), }, } } @@ -350,13 +417,56 @@ mod tests { }; use aionui_common::AgentType; use aionui_db::{ - IAgentMetadataRepository, SqliteAgentMetadataRepository, UpsertAgentMetadataParams, init_database_memory, + CreateProviderParams, IAgentMetadataRepository, IProviderRepository, SqliteAgentMetadataRepository, + SqliteProviderRepository, UpsertAgentMetadataParams, init_database_memory, }; use tokio::time::Duration; - use super::{AgentAvailabilityService, run_probe}; + use super::{AgentAvailabilityService, probe_aionrs_provider_readiness, run_probe}; use crate::registry::AgentRegistry; + fn enabled_provider_params() -> CreateProviderParams<'static> { + CreateProviderParams { + id: None, + platform: "openai", + name: "OpenAI", + base_url: "https://api.openai.com", + api_key_encrypted: "enc", + models: r#"["gpt-4"]"#, + enabled: true, + capabilities: r#"[{"type":"text"}]"#, + context_limit: None, + model_protocols: None, + model_enabled: None, + model_health: None, + bedrock_config: None, + is_full_url: false, + } + } + + #[tokio::test] + async fn aionrs_is_offline_without_an_enabled_provider() { + let db = init_database_memory().await.unwrap(); + let provider_repo: Arc = Arc::new(SqliteProviderRepository::new(db.pool().clone())); + + let (status, code, _msg) = probe_aionrs_provider_readiness(&provider_repo).await; + + assert_eq!(status, AgentSnapshotCheckStatus::Offline); + assert_eq!(code.as_deref(), Some("no_provider")); + } + + #[tokio::test] + async fn aionrs_is_online_when_a_provider_is_enabled() { + let db = init_database_memory().await.unwrap(); + let provider_repo: Arc = Arc::new(SqliteProviderRepository::new(db.pool().clone())); + provider_repo.create(enabled_provider_params()).await.unwrap(); + + let (status, code, _msg) = probe_aionrs_provider_readiness(&provider_repo).await; + + assert_eq!(status, AgentSnapshotCheckStatus::Online); + assert!(code.is_none()); + } + #[tokio::test] async fn record_session_failure_persists_unavailable_snapshot() { let db = init_database_memory().await.unwrap(); @@ -394,7 +504,8 @@ mod tests { let registry = AgentRegistry::new(repo); registry.hydrate().await.unwrap(); - let service = AgentAvailabilityService::new(registry.clone(), std::env::temp_dir()); + let provider_repo: Arc = Arc::new(SqliteProviderRepository::new(db.pool().clone())); + let service = AgentAvailabilityService::new(registry.clone(), provider_repo, std::env::temp_dir()); service .record_session_failure( "agent-session-failure", @@ -464,9 +575,11 @@ mod tests { let registry = AgentRegistry::new(repo); registry.hydrate().await.unwrap(); + let provider_repo: Arc = Arc::new(SqliteProviderRepository::new(db.pool().clone())); let service = AgentAvailabilityService { registry: registry.clone(), data_dir: std::env::temp_dir(), + provider_repo, scheduler_started: Arc::new(AtomicBool::new(false)), startup_delay: Duration::from_millis(10), scheduled_interval: Duration::from_secs(60), @@ -498,6 +611,7 @@ mod tests { async fn managed_builtin_probe_checks_primary_binary_before_running_bridge_command() { let db = init_database_memory().await.unwrap(); let repo: Arc = Arc::new(SqliteAgentMetadataRepository::new(db.pool().clone())); + let provider_repo: Arc = Arc::new(SqliteProviderRepository::new(db.pool().clone())); let registry = AgentRegistry::new(repo); registry.hydrate().await.unwrap(); @@ -549,6 +663,7 @@ mod tests { let snapshot = run_probe( ®istry, + &provider_repo, &meta, std::env::temp_dir().as_path(), AgentSnapshotCheckKind::Manual, diff --git a/crates/aionui-ai-agent/src/services/custom.rs b/crates/aionui-ai-agent/src/services/custom.rs index af24b9295..577431996 100644 --- a/crates/aionui-ai-agent/src/services/custom.rs +++ b/crates/aionui-ai-agent/src/services/custom.rs @@ -230,6 +230,13 @@ async fn probe_or_reject(req: &CustomAgentUpsertRequest, data_dir: &Path) -> Res let env_map: HashMap = req.env.iter().map(|e| (e.name.clone(), e.value.clone())).collect(); match probe(&req.command, &req.args, &env_map, data_dir, None).await { TryConnectCustomAgentResponse::Success => Ok(()), + // Reachable but not authorized is a valid agent the user simply hasn't + // logged into yet — accept the save so it lands in the list (offline, + // needs-login), where a later "test connection" confirms recovery. + TryConnectCustomAgentResponse::FailAuth { error } => { + tracing::info!(%error, "custom agent reachable but requires auth; accepting save"); + Ok(()) + } TryConnectCustomAgentResponse::FailCli { error } => { Err(AgentError::bad_request(format!("cli_not_found: {error}"))) } diff --git a/crates/aionui-api-types/src/acp.rs b/crates/aionui-api-types/src/acp.rs index 6f00d23e3..122853ce2 100644 --- a/crates/aionui-api-types/src/acp.rs +++ b/crates/aionui-api-types/src/acp.rs @@ -148,16 +148,22 @@ pub struct TryConnectCustomAgentRequest { /// Outcome of [`TryConnectCustomAgentRequest`]. /// -/// Tagged enum: `step` distinguishes the three states the frontend's -/// Alert component renders (success → green, fail_cli → red, -/// fail_acp → yellow). `error` carries a human-readable reason for the -/// two failure variants. +/// Tagged enum: `step` distinguishes the states the frontend's Alert component +/// renders (success → green, fail_cli → red, fail_acp → yellow, fail_auth → +/// yellow with a "needs login" hint). `error` carries a human-readable reason +/// for the failure variants. +/// +/// The probe reaches `session/new` (not just `initialize`), so `fail_auth` +/// distinguishes "reachable but not authorized" (ACP `auth_required`, +/// JSON-RPC `-32000`) from other ACP failures — `initialize` alone cannot +/// tell these apart. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "step", rename_all = "snake_case")] pub enum TryConnectCustomAgentResponse { Success, FailCli { error: String }, FailAcp { error: String }, + FailAuth { error: String }, } /// Query parameters for workspace browse. @@ -310,6 +316,16 @@ mod tests { serde_json::to_value(&fail).unwrap(), serde_json::json!({"step":"fail_cli","error":"not found"}) ); + + // Reachable-but-unauthorized is its own tag so the UI can show a + // "needs login" hint instead of a generic ACP failure. + let auth = TryConnectCustomAgentResponse::FailAuth { + error: "requires login".into(), + }; + assert_eq!( + serde_json::to_value(&auth).unwrap(), + serde_json::json!({"step":"fail_auth","error":"requires login"}) + ); } #[test] From 82f848e96620416a2f2d5d3e3b3d4fe6fa55ebb1 Mon Sep 17 00:00:00 2001 From: zk <> Date: Mon, 22 Jun 2026 16:53:35 +0800 Subject: [PATCH 118/135] fix(team): bootstrap TeamRun for assistant-first creation --- crates/aionui-team/src/guide/server.rs | 146 ++++++++++++++++++++++++- crates/aionui-team/src/service.rs | 17 +++ 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/crates/aionui-team/src/guide/server.rs b/crates/aionui-team/src/guide/server.rs index 6eafff827..d6f63adef 100644 --- a/crates/aionui-team/src/guide/server.rs +++ b/crates/aionui-team/src/guide/server.rs @@ -328,10 +328,52 @@ async fn exec_create_team( } }; + let lead_slot_id = match team.leader_assistant_id.as_deref().or_else(|| { + team.assistants + .iter() + .find(|assistant| assistant.role == "leader" || assistant.role == "lead") + .map(|assistant| assistant.slot_id.as_str()) + }) { + Some(slot_id) if !slot_id.is_empty() => slot_id, + _ => { + warn!( + team_id = %team.id, + "Guide HTTP: aion_create_team created team but response did not include a leader slot" + ); + return error_response("Created team is missing a leader slot."); + } + }; + + let team_run = match svc.accept_assistant_first_team_run(&team.id, lead_slot_id).await { + Ok(ack) => ack, + Err(error) => { + warn!( + team_id = %team.id, + lead_slot_id, + error = %error, + "Guide HTTP: aion_create_team created team but failed to open assistant-first TeamRun" + ); + let route = format!("/team/{}", team.id); + return serde_json::json!({ + "teamId": team.id, + "name": team.name, + "route": route, + "status": "team_created", + "error": GUIDE_NO_ACTIVE_TEAM_RUN_HANDOFF_ERROR, + "next_step": GUIDE_NO_ACTIVE_TEAM_RUN_HANDOFF_ERROR + }); + } + }; + let route = format!("/team/{}", team.id); - info!(team_id = %team.id, "Guide HTTP: aion_create_team succeeded"); + info!( + team_id = %team.id, + team_run_id = %team_run.team_run_id, + "Guide HTTP: aion_create_team succeeded" + ); serde_json::json!({ "teamId": team.id, + "teamRunId": team_run.team_run_id, "name": team.name, "route": route, "status": "team_created", @@ -987,6 +1029,108 @@ mod tests { assert!(next_step.contains("claude/codex")); } + #[tokio::test] + async fn create_team_opens_active_team_run_for_assistant_first_tools() { + let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { + row: AssistantDefinitionRow { + definition_id: "def-guide-teamrun".into(), + assistant_key: "assistant-teamrun".into(), + source: "user".into(), + owner_type: "user".into(), + source_ref: None, + source_version: None, + source_hash: None, + name: "TeamRun Assistant".into(), + name_i18n: "{}".into(), + description: None, + description_i18n: "{}".into(), + avatar_type: "emoji".into(), + avatar_value: Some("🤖".into()), + agent_backend: "claude".into(), + rule_resource_type: "inline".into(), + rule_resource_ref: None, + rule_inline_content: None, + recommended_prompts: "[]".into(), + recommended_prompts_i18n: "{}".into(), + default_model_mode: "auto".into(), + default_model_value: None, + default_permission_mode: "auto".into(), + default_permission_value: None, + default_skills_mode: "auto".into(), + default_skill_ids: "[]".into(), + custom_skill_names: "[]".into(), + default_disabled_builtin_skill_ids: "[]".into(), + default_mcps_mode: "auto".into(), + default_mcp_ids: "[]".into(), + created_at: 0, + updated_at: 0, + deleted_at: None, + }, + }); + let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { + row: AssistantOverlayRow { + definition_id: "def-guide-teamrun".into(), + enabled: true, + sort_order: 0, + agent_backend_override: Some("claude".into()), + last_used_at: None, + created_at: 0, + updated_at: 0, + }, + }); + let (svc, _team_repo, _task_manager, conv_repo) = + setup_with_assistants_team_repo_and_conversation_repo(definition_repo, overlay_repo); + + conv_repo + .create(&ConversationRow { + id: "caller-conv-teamrun".into(), + user_id: "system_default_user".into(), + name: "Caller".into(), + r#type: "acp".into(), + pinned: false, + pinned_at: None, + source: None, + channel_chat_id: None, + extra: serde_json::json!({ + "assistant_id": "assistant-teamrun", + "workspace": "/tmp/guide-teamrun-workspace" + }) + .to_string(), + model: None, + status: Some("completed".into()), + created_at: 0, + updated_at: 0, + }) + .await + .expect("seed caller conversation"); + + let server = GuideMcpServer::start().await.expect("start guide server"); + server.set_service(Arc::downgrade(&svc)).await; + + let client = reqwest::Client::new(); + let resp = client + .post(format!("http://127.0.0.1:{}/tool", server.http_port())) + .header("Authorization", format!("Bearer {}", server.auth_token())) + .json(&serde_json::json!({ + "tool": "aion_create_team", + "args": { "summary": "create a debate team" }, + "conversation_id": "caller-conv-teamrun", + "user_id": "system_default_user" + })) + .send() + .await + .expect("call guide create team"); + assert_eq!(resp.status(), StatusCode::OK); + + let body: serde_json::Value = resp.json().await.expect("guide create team response"); + let team_id = body["teamId"].as_str().expect("team id in response"); + + assert!(is_run_scoped_guide_team_tool("team_spawn_agent")); + svc.require_active_team_run_for_team_work(team_id) + .await + .expect("assistant-first create_team should open a TeamRun before run-scoped tools are used"); + } + #[tokio::test] async fn create_team_next_step_does_not_echo_backend_only_teammate_plan() { let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { diff --git a/crates/aionui-team/src/service.rs b/crates/aionui-team/src/service.rs index 0cbf0a52f..98f7461a8 100644 --- a/crates/aionui-team/src/service.rs +++ b/crates/aionui-team/src/service.rs @@ -1080,6 +1080,23 @@ impl TeamSessionService { )) } + pub(crate) async fn accept_assistant_first_team_run( + &self, + team_id: &str, + lead_slot_id: &str, + ) -> Result { + let entry = self + .sessions + .get(team_id) + .ok_or_else(|| TeamError::SessionNotFound(team_id.into()))?; + let manager = entry.session.team_run_manager().clone(); + drop(entry); + + manager + .accept_user_message(lead_slot_id, TeamRunTargetRole::Lead, true, None) + .await + } + pub(crate) async fn notify_leader_spawn_attach_failed( &self, team_id: &str, From 5f0528814e7b0d0f53f89772926b4d7c1ac2968a Mon Sep 17 00:00:00 2001 From: zk <> Date: Mon, 22 Jun 2026 17:19:43 +0800 Subject: [PATCH 119/135] fix(assistant): resolve aionrs agent status by agent_type, not just backend An assistant's agent_status was matched to its agent row by `backend` only. aionrs (the built-in Rust agent) has a NULL `backend` and is keyed by `agent_type` ("aionrs"), so every aionrs-backed assistant failed to resolve a row and was mislabelled Missing/unavailable. Match the agent row on `backend == effective_backend` OR `agent_type.serde_name() == effective_backend`, so aionrs assistants resolve to the real aionrs row and reflect its actual status. Add a regression test covering an aionrs assistant (row with NULL backend, agent_type Aionrs, Online) resolving to Online instead of Missing. --- crates/aionui-assistant/src/service.rs | 47 ++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 95739b9ac..3672d532b 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -1872,6 +1872,13 @@ fn assistant_projection_for_definition( .and_then(|row| row.agent_backend_override.as_deref()) .unwrap_or(definition.agent_backend.as_str()); + // An agent row identifies its runtime key by `backend` for vendor ACP + // agents, but aionrs (the built-in Rust agent) has a NULL `backend` and is + // keyed by its `agent_type` ("aionrs") instead. Match on either so aionrs + // assistants resolve to the aionrs row rather than falling back to Missing. + let row_matches_backend = + |row: &&AgentManagementRow| row.backend.as_deref() == Some(effective_backend) || row.agent_type.serde_name() == effective_backend; + let agent_row = if matches!(source, AssistantSource::Bare) { definition .source_ref @@ -1880,12 +1887,8 @@ fn assistant_projection_for_definition( } else { agent_rows .iter() - .find(|row| row.backend.as_deref() == Some(effective_backend) && row.agent_source != AgentSource::Custom) - .or_else(|| { - agent_rows - .iter() - .find(|row| row.backend.as_deref() == Some(effective_backend)) - }) + .find(|row| row_matches_backend(row) && row.agent_source != AgentSource::Custom) + .or_else(|| agent_rows.iter().find(row_matches_backend)) }; let agent_status = agent_row @@ -2578,6 +2581,38 @@ mod tests { assert_eq!(bare.preset_agent_type, "aionrs"); } + #[tokio::test] + async fn aionrs_assistant_resolves_agent_status_via_agent_type_not_backend() { + // Regression: an assistant whose engine is aionrs must match the aionrs + // agent row by `agent_type` ("aionrs"), since that row's `backend` is + // NULL. Matching on `backend` alone left the row unresolved and + // mislabelled every aionrs assistant as Missing/unavailable. + let mut aionrs_row = mk_agent_row("agent-aionrs", "aionrs", aionui_api_types::AgentManagementStatus::Online); + aionrs_row.backend = None; + aionrs_row.agent_type = aionui_common::AgentType::Aionrs; + + let mut builtin = mk_builtin("builtin-aionrs", "Aion Assistant"); + builtin.preset_agent_type = "aionrs".into(); + + let fx = fixture_with_options(FixtureOpts { + builtins: vec![builtin], + agent_rows: vec![aionrs_row], + ..Default::default() + }) + .await; + + let list = fx.service.list().await.unwrap(); + let assistant = list + .iter() + .find(|assistant| assistant.id == "builtin-aionrs") + .expect("aionrs builtin assistant should be listed"); + assert_eq!( + assistant.agent_status, + aionui_api_types::AgentManagementStatus::Online, + "aionrs assistant should resolve to the online aionrs agent row, not Missing" + ); + } + #[tokio::test] async fn bootstrap_places_new_bare_assistants_before_existing_assistants() { let fx = fixture_with_options(FixtureOpts { From 65159aeb5f57e28f621bf09a3fb2af2aad489296 Mon Sep 17 00:00:00 2001 From: zk <> Date: Mon, 22 Jun 2026 17:21:00 +0800 Subject: [PATCH 120/135] chore: apply auto-fixes (fmt + clippy) --- crates/aionui-assistant/src/service.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 3672d532b..8fcdc2854 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -1876,8 +1876,9 @@ fn assistant_projection_for_definition( // agents, but aionrs (the built-in Rust agent) has a NULL `backend` and is // keyed by its `agent_type` ("aionrs") instead. Match on either so aionrs // assistants resolve to the aionrs row rather than falling back to Missing. - let row_matches_backend = - |row: &&AgentManagementRow| row.backend.as_deref() == Some(effective_backend) || row.agent_type.serde_name() == effective_backend; + let row_matches_backend = |row: &&AgentManagementRow| { + row.backend.as_deref() == Some(effective_backend) || row.agent_type.serde_name() == effective_backend + }; let agent_row = if matches!(source, AssistantSource::Bare) { definition @@ -2587,7 +2588,11 @@ mod tests { // agent row by `agent_type` ("aionrs"), since that row's `backend` is // NULL. Matching on `backend` alone left the row unresolved and // mislabelled every aionrs assistant as Missing/unavailable. - let mut aionrs_row = mk_agent_row("agent-aionrs", "aionrs", aionui_api_types::AgentManagementStatus::Online); + let mut aionrs_row = mk_agent_row( + "agent-aionrs", + "aionrs", + aionui_api_types::AgentManagementStatus::Online, + ); aionrs_row.backend = None; aionrs_row.agent_type = aionui_common::AgentType::Aionrs; From 0eefd92a25c22c932207009165fae0d9caefd80e Mon Sep 17 00:00:00 2001 From: zk <> Date: Mon, 22 Jun 2026 17:40:53 +0800 Subject: [PATCH 121/135] fix(ci): stabilize agent availability checks --- .../tests/agent_availability_integration.rs | 32 ++++++++++++++----- crates/aionui-team/src/service.rs | 1 + 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/crates/aionui-ai-agent/tests/agent_availability_integration.rs b/crates/aionui-ai-agent/tests/agent_availability_integration.rs index 8e38f98c2..01b6c4fb9 100644 --- a/crates/aionui-ai-agent/tests/agent_availability_integration.rs +++ b/crates/aionui-ai-agent/tests/agent_availability_integration.rs @@ -7,7 +7,12 @@ use aionui_db::{ UpsertAgentMetadataParams, init_database_memory, }; -fn custom_params<'a>(id: &'a str, name: &'a str, command: &'a str) -> UpsertAgentMetadataParams<'a> { +fn custom_params<'a>( + id: &'a str, + name: &'a str, + command: &'a str, + agent_source_info: &'a str, +) -> UpsertAgentMetadataParams<'a> { UpsertAgentMetadataParams { id, icon: None, @@ -18,7 +23,7 @@ fn custom_params<'a>(id: &'a str, name: &'a str, command: &'a str) -> UpsertAgen backend: Some("claude"), agent_type: "acp", agent_source: "custom", - agent_source_info: Some(r#"{"binary_name":"claude"}"#), + agent_source_info: Some(agent_source_info), enabled: true, command: Some(command), args: Some("[]"), @@ -45,15 +50,26 @@ async fn management_rows_derive_missing_available_and_unavailable_statuses() { "agent-missing", "Missing Agent", "aionui-missing-agent-binary", + r#"{"binary_name":"aionui-missing-agent-binary"}"#, + )) + .await + .unwrap(); + repo.upsert(&custom_params( + "agent-unavailable", + "Unavailable Agent", + "cargo", + r#"{"binary_name":"cargo"}"#, + )) + .await + .unwrap(); + repo.upsert(&custom_params( + "agent-available", + "Available Agent", + "cargo", + r#"{"binary_name":"cargo"}"#, )) .await .unwrap(); - repo.upsert(&custom_params("agent-unavailable", "Unavailable Agent", "cargo")) - .await - .unwrap(); - repo.upsert(&custom_params("agent-available", "Available Agent", "cargo")) - .await - .unwrap(); repo.update_availability_snapshot( "agent-unavailable", diff --git a/crates/aionui-team/src/service.rs b/crates/aionui-team/src/service.rs index 98f7461a8..56094dbeb 100644 --- a/crates/aionui-team/src/service.rs +++ b/crates/aionui-team/src/service.rs @@ -80,6 +80,7 @@ pub struct TeamSessionService { } impl TeamSessionService { + #[allow(clippy::too_many_arguments)] pub fn new( repo: Arc, agent_metadata_repo: Arc, From cee9c096c44cbc8d5c162d6e162b474cba1cc17f Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 23 Jun 2026 11:30:07 +0800 Subject: [PATCH 122/135] fix(assistant): unify assistant agent id storage --- crates/aionui-app/src/router/state.rs | 17 +- crates/aionui-app/tests/assistants_e2e.rs | 8 +- crates/aionui-app/tests/conversation_e2e.rs | 6 +- crates/aionui-assistant/src/service.rs | 179 +++++++++++++----- crates/aionui-channel/src/channel_settings.rs | 97 +++++++--- .../tests/message_service_integration.rs | 8 +- crates/aionui-conversation/src/convert.rs | 3 +- crates/aionui-conversation/src/service.rs | 111 ++++++----- .../aionui-conversation/src/service_test.rs | 117 ++++++++---- crates/aionui-cron/src/service.rs | 54 ++++-- .../aionui-cron/tests/service_integration.rs | 24 ++- .../013_agent_connection_snapshot.sql | 169 +++++++++++++++++ crates/aionui-db/src/agent_binding.rs | 68 +++++++ crates/aionui-db/src/lib.rs | 5 + crates/aionui-db/src/models/assistant.rs | 8 +- crates/aionui-db/src/models/conversation.rs | 4 +- .../src/repository/sqlite_assistant.rs | 79 ++++++-- .../src/repository/sqlite_conversation.rs | 6 +- .../aionui-db/tests/agent_binding_resolver.rs | 36 ++++ .../assistant_data_unification_schema.rs | 32 +++- crates/aionui-team/src/guide/server.rs | 20 +- crates/aionui-team/src/provisioning.rs | 15 +- crates/aionui-team/src/service.rs | 1 + .../src/service/describe_support.rs | 21 +- .../aionui-team/src/service/spawn_support.rs | 56 ++++-- .../tests/session_service_integration.rs | 26 +-- 26 files changed, 889 insertions(+), 281 deletions(-) create mode 100644 crates/aionui-db/src/agent_binding.rs create mode 100644 crates/aionui-db/tests/agent_binding_resolver.rs diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index b582d78bd..8a0106adc 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -487,12 +487,16 @@ pub async fn build_channel_state( let pref_repo: Arc = Arc::new(SqliteClientPreferenceRepository::new(pref_pool)); let channel_settings = Arc::new( - aionui_channel::channel_settings::ChannelSettingsService::new(pref_repo).with_assistant_repos( - Arc::new(SqliteAssistantDefinitionRepository::new( + aionui_channel::channel_settings::ChannelSettingsService::new(pref_repo) + .with_agent_metadata_repo(Arc::new(SqliteAgentMetadataRepository::new( services.database.pool().clone(), - )), - Arc::new(SqliteAssistantOverlayRepository::new(services.database.pool().clone())), - ), + ))) + .with_assistant_repos( + Arc::new(SqliteAssistantDefinitionRepository::new( + services.database.pool().clone(), + )), + Arc::new(SqliteAssistantOverlayRepository::new(services.database.pool().clone())), + ), ); // Build orchestrator dependencies @@ -643,7 +647,7 @@ pub fn build_cron_state(services: &AppServices) -> CronRouterState { skill_resolver, services.worker_task_manager.clone(), conv_repo.clone(), - agent_metadata_repo, + agent_metadata_repo.clone(), acp_session_repo, ) .with_runtime_state(services.conversation_runtime_state.clone()); @@ -690,6 +694,7 @@ pub fn build_cron_state(services: &AppServices) -> CronRouterState { let assistant_overlay_repo = Arc::new(SqliteAssistantOverlayRepository::new(services.database.pool().clone())); let cron_service = Arc::new(aionui_cron::service::CronService::new( cron_repo, + agent_metadata_repo, assistant_definition_repo, assistant_overlay_repo, scheduler, diff --git a/crates/aionui-app/tests/assistants_e2e.rs b/crates/aionui-app/tests/assistants_e2e.rs index 3cc82b0a3..6f7e747ed 100644 --- a/crates/aionui-app/tests/assistants_e2e.rs +++ b/crates/aionui-app/tests/assistants_e2e.rs @@ -79,7 +79,7 @@ async fn insert_generated_bare_assistant( description_i18n: "{}", avatar_type: "none", avatar_value: None, - agent_backend: backend, + agent_id: backend, rule_resource_type: "none", rule_resource_ref: None, rule_inline_content: None, @@ -103,7 +103,7 @@ async fn insert_generated_bare_assistant( definition_id: &format!("asstdef-{assistant_key}"), enabled: true, sort_order: 5, - agent_backend_override: None, + agent_id_override: None, last_used_at: None, }) .await @@ -471,7 +471,7 @@ async fn get_detail_returns_definition_state_preferences_and_rules() { description_i18n: &definition.description_i18n, avatar_type: &definition.avatar_type, avatar_value: definition.avatar_value.as_deref(), - agent_backend: &definition.agent_backend, + agent_id: &definition.agent_id, rule_resource_type: &definition.rule_resource_type, rule_resource_ref: definition.rule_resource_ref.as_deref(), rule_inline_content: definition.rule_inline_content.as_deref(), @@ -495,7 +495,7 @@ async fn get_detail_returns_definition_state_preferences_and_rules() { definition_id: &definition.definition_id, enabled: false, sort_order: 7, - agent_backend_override: Some("codex"), + agent_id_override: Some("8e1acf31"), last_used_at: Some(1_725_000_001_234), }) .await diff --git a/crates/aionui-app/tests/conversation_e2e.rs b/crates/aionui-app/tests/conversation_e2e.rs index 990601bec..7f85ad86f 100644 --- a/crates/aionui-app/tests/conversation_e2e.rs +++ b/crates/aionui-app/tests/conversation_e2e.rs @@ -167,7 +167,7 @@ async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { description_i18n: &definition.description_i18n, avatar_type: &definition.avatar_type, avatar_value: definition.avatar_value.as_deref(), - agent_backend: &definition.agent_backend, + agent_id: &definition.agent_id, rule_resource_type: &definition.rule_resource_type, rule_resource_ref: definition.rule_resource_ref.as_deref(), rule_inline_content: definition.rule_inline_content.as_deref(), @@ -191,7 +191,7 @@ async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { definition_id: &definition.definition_id, enabled: true, sort_order: 0, - agent_backend_override: Some("codex"), + agent_id_override: Some("8e1acf31"), last_used_at: None, }) .await @@ -258,7 +258,7 @@ async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { .unwrap() .unwrap(); assert_eq!(snapshot.assistant_key, "u1"); - assert_eq!(snapshot.agent_backend, "codex"); + assert_eq!(snapshot.agent_id, "8e1acf31"); assert_eq!(snapshot.rules_content, "assistant snapshot rule"); assert_eq!(snapshot.resolved_permission_value.as_deref(), Some("workspace-write")); assert_eq!(snapshot.resolved_skill_ids, r#"["override-skill"]"#); diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 8fcdc2854..19907a9cc 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -19,6 +19,7 @@ use aionui_db::{ IAssistantOverlayRepository, IAssistantOverrideRepository, IAssistantPreferenceRepository, IAssistantRepository, IProviderRepository, SqlitePool, UpdateAssistantParams, UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, UpsertAssistantPreferenceParams, rebuild_legacy_assistant_mirror, + resolve_agent_binding, }; use aionui_extension::{AssistantClassifier, AssistantRuleDispatcher, ExtensionError}; use serde_json; @@ -135,6 +136,9 @@ impl AssistantService { let (definition_id, assistant_key) = self .resolve_definition_identity("builtin", Some(&builtin.id), &builtin.id) .await?; + let agent_id = self + .resolve_agent_id_for_runtime_backend(&builtin.preset_agent_type) + .await?; self.definition_repo .upsert(&UpsertAssistantDefinitionParams { @@ -151,7 +155,7 @@ impl AssistantService { description_i18n: &description_i18n, avatar_type: &avatar_type, avatar_value: avatar_value.as_deref(), - agent_backend: &builtin.preset_agent_type, + agent_id: &agent_id, rule_resource_type: if builtin.rule_file.is_some() { "builtin_asset" } else { @@ -233,12 +237,17 @@ impl AssistantService { continue; }; + let agent_id_override = match override_row.preset_agent_type.as_deref() { + Some(value) => Some(self.resolve_agent_id_for_runtime_backend(value).await?), + None => None, + }; + self.state_repo .upsert(&UpsertAssistantOverlayParams { definition_id: &definition.definition_id, enabled: override_row.enabled, sort_order: override_row.sort_order, - agent_backend_override: override_row.preset_agent_type.as_deref(), + agent_id_override: agent_id_override.as_deref(), last_used_at: override_row.last_used_at, }) .await @@ -296,18 +305,6 @@ impl AssistantService { .resolve_definition_identity("generated", Some(&row.id), &assistant_key) .await?; let avatar_value = row.icon.as_deref().filter(|value| !value.trim().is_empty()); - // ACP agents expose their engine in `backend` (claude/gemini/…), but - // single-engine agents like Aion CLI carry it in `agent_type` and - // leave `backend` empty. Fall back to `agent_type` so the bare - // assistant always has a concrete `preset_agent_type` for the - // frontend to route on; an empty backend would otherwise drop the - // top-level model and fail warmup with "Provider '' not found". - let backend = row - .backend - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| row.agent_type.serde_name()); - self.definition_repo .upsert(&UpsertAssistantDefinitionParams { definition_id: &definition_id, @@ -323,7 +320,7 @@ impl AssistantService { description_i18n: "{}", avatar_type: if avatar_value.is_some() { "emoji" } else { "none" }, avatar_value, - agent_backend: backend, + agent_id: &row.id, rule_resource_type: "none", rule_resource_ref: None, rule_inline_content: None, @@ -360,7 +357,7 @@ impl AssistantService { definition_id: &definition_id, enabled: true, sort_order: initial_generated_sort_order.clamp(i32::MIN as i64, i32::MAX as i64) as i32, - agent_backend_override: None, + agent_id_override: None, last_used_at: None, }) .await @@ -385,6 +382,9 @@ impl AssistantService { normalize_json_array_string(row.disabled_builtin_skills.as_deref(), "disabled_builtin_skills")?; let (avatar_type, avatar_value) = serialize_avatar("user", row.avatar.as_deref()); let (definition_id, assistant_key) = self.resolve_definition_identity("user", Some(&row.id), &row.id).await?; + let agent_id = self + .resolve_agent_id_for_runtime_backend(&row.preset_agent_type) + .await?; self.definition_repo .upsert(&UpsertAssistantDefinitionParams { @@ -401,7 +401,7 @@ impl AssistantService { description_i18n: &description_i18n, avatar_type: &avatar_type, avatar_value: avatar_value.as_deref(), - agent_backend: &row.preset_agent_type, + agent_id: &agent_id, rule_resource_type: "user_file", rule_resource_ref: Some(&row.id), rule_inline_content: None, @@ -497,7 +497,7 @@ impl AssistantService { description_i18n: &patched.description_i18n, avatar_type: &patched.avatar_type, avatar_value: patched.avatar_value.as_deref(), - agent_backend: &patched.agent_backend, + agent_id: &patched.agent_id, rule_resource_type: &patched.rule_resource_type, rule_resource_ref: patched.rule_resource_ref.as_deref(), rule_inline_content: patched.rule_inline_content.as_deref(), @@ -601,8 +601,9 @@ impl AssistantService { let mut result = Vec::new(); for definition in &definitions { - let projection = - assistant_projection_for_definition(definition, state_map.get(&definition.definition_id), &projections); + let projection = self + .project_definition(definition, state_map.get(&definition.definition_id), &projections) + .await?; result.push(definition_to_response( definition, state_map.get(&definition.definition_id), @@ -631,7 +632,9 @@ impl AssistantService { let projections = self.reconcile_generated_assistants().await?; if let Some(definition) = self.definition_repo.get_by_key(id).await? { let state = self.state_repo.get(&definition.definition_id).await?; - let projection = assistant_projection_for_definition(&definition, state.as_ref(), &projections); + let projection = self + .project_definition(&definition, state.as_ref(), &projections) + .await?; return definition_to_response(&definition, state.as_ref(), &projection); } @@ -644,7 +647,9 @@ impl AssistantService { let state = self.state_repo.get(&definition.definition_id).await?; let preference = self.preference_repo.get(&definition.definition_id).await?; let rules_content = self.read_rule(id, locale).await?; - let projection = assistant_projection_for_definition(&definition, state.as_ref(), &projections); + let projection = self + .project_definition(&definition, state.as_ref(), &projections) + .await?; return definition_to_detail_response( &definition, state.as_ref(), @@ -695,6 +700,38 @@ impl AssistantService { } } + async fn resolve_agent_id_for_runtime_backend(&self, backend: &str) -> Result { + let trimmed = backend.trim(); + let Some(binding) = resolve_agent_binding(&self.pool, trimmed) + .await + .map_err(|e| AssistantError::Internal(format!("resolve agent binding: {e}")))? + else { + return Err(AssistantError::BadRequest(format!( + "Unknown preset_agent_type '{trimmed}'" + ))); + }; + Ok(binding.agent_id) + } + + async fn project_definition( + &self, + definition: &AssistantDefinitionRow, + state: Option<&AssistantOverlayRow>, + agent_rows: &[AgentManagementRow], + ) -> Result { + let effective_agent_id = effective_agent_id_for_definition(definition, state); + let runtime_backend = resolve_agent_binding(&self.pool, effective_agent_id) + .await + .map_err(|e| AssistantError::Internal(format!("resolve agent binding: {e}")))? + .map(|binding| binding.runtime_backend); + Ok(assistant_projection_for_definition( + definition, + state, + agent_rows, + runtime_backend.as_deref(), + )) + } + // ----------------------------------------------------------------------- // Create / Update / Delete // ----------------------------------------------------------------------- @@ -803,20 +840,21 @@ impl AssistantService { let enabled = existing.as_ref().is_none_or(|o| o.enabled); let sort_order = existing.as_ref().map(|o| o.sort_order).unwrap_or(0); let last_used_at = existing.as_ref().and_then(|o| o.last_used_at); - let current_agent_backend = self + let requested_agent_id = self.resolve_agent_id_for_runtime_backend(preset_agent_type).await?; + let current_agent_id = self .state_repo .get(&definition.definition_id) .await .map_err(|e| AssistantError::Internal(format!("get assistant overlay: {e}")))? - .and_then(|row| row.agent_backend_override) - .unwrap_or_else(|| definition.agent_backend.clone()); - let reset_model_and_permission = current_agent_backend != preset_agent_type; + .and_then(|row| row.agent_id_override) + .unwrap_or_else(|| definition.agent_id.clone()); + let reset_model_and_permission = current_agent_id != requested_agent_id; self.state_repo .upsert(&UpsertAssistantOverlayParams { definition_id: &definition.definition_id, enabled, sort_order, - agent_backend_override: Some(preset_agent_type), + agent_id_override: Some(&requested_agent_id), last_used_at, }) .await @@ -851,10 +889,13 @@ impl AssistantService { .get_by_key(id) .await? .ok_or_else(|| AssistantError::NotFound(format!("assistant '{id}' not found")))?; - let reset_model_and_permission = req - .preset_agent_type + let requested_agent_id = match req.preset_agent_type.as_deref() { + Some(preset_agent_type) => Some(self.resolve_agent_id_for_runtime_backend(preset_agent_type).await?), + None => None, + }; + let reset_model_and_permission = requested_agent_id .as_deref() - .is_some_and(|preset_agent_type| preset_agent_type != current_definition.agent_backend); + .is_some_and(|agent_id| agent_id != current_definition.agent_id); let normalized_avatar = if req.avatar.is_some() { Some(self.normalize_user_avatar_input(id, req.avatar.as_deref())?) } else { @@ -1115,17 +1156,24 @@ impl AssistantService { .last_used_at .or_else(|| existing_state.as_ref().and_then(|state| state.last_used_at)) .or_else(|| existing.as_ref().and_then(|o| o.last_used_at)); - let agent_backend_override = existing_state + let agent_id_override = if let Some(value) = existing_state .as_ref() - .and_then(|state| state.agent_backend_override.as_deref()) - .or_else(|| existing.as_ref().and_then(|o| o.preset_agent_type.as_deref())); + .and_then(|state| state.agent_id_override.clone()) + { + Some(value) + } else { + match existing.as_ref().and_then(|o| o.preset_agent_type.as_deref()) { + Some(value) => Some(self.resolve_agent_id_for_runtime_backend(value).await?), + None => None, + } + }; let state = self .state_repo .upsert(&UpsertAssistantOverlayParams { definition_id: &definition.definition_id, enabled, sort_order, - agent_backend_override, + agent_id_override: agent_id_override.as_deref(), last_used_at, }) .await @@ -1729,9 +1777,7 @@ fn definition_to_response( avatar: avatar_display_value(definition), enabled: state.is_none_or(|row| row.enabled), sort_order: state.map(|row| row.sort_order).unwrap_or(0), - preset_agent_type: state - .and_then(|row| row.agent_backend_override.clone()) - .unwrap_or_else(|| definition.agent_backend.clone()), + preset_agent_type: projection.runtime_backend.clone(), enabled_skills: decode_str_list(Some(definition.default_skill_ids.as_str()))?, custom_skill_names: decode_str_list(Some(definition.custom_skill_names.as_str()))?, disabled_builtin_skills: decode_str_list(Some(definition.default_disabled_builtin_skill_ids.as_str()))?, @@ -1799,9 +1845,7 @@ fn definition_to_detail_response( last_used_at: state.and_then(|row| row.last_used_at), }, engine: AssistantEngineResponse { - agent_backend: state - .and_then(|row| row.agent_backend_override.clone()) - .unwrap_or_else(|| definition.agent_backend.clone()), + agent_backend: projection.runtime_backend.clone(), }, rules: AssistantRulesResponse { content: if rules_content.is_empty() { @@ -1850,6 +1894,7 @@ fn definition_to_detail_response( #[derive(Debug, Clone)] struct AssistantRuntimeProjection { + runtime_backend: String, agent_status: AgentManagementStatus, agent_status_message: Option, team_selectable: bool, @@ -1861,6 +1906,7 @@ fn assistant_projection_for_definition( definition: &AssistantDefinitionRow, state: Option<&AssistantOverlayRow>, agent_rows: &[AgentManagementRow], + resolved_runtime_backend: Option<&str>, ) -> AssistantRuntimeProjection { let enabled = state.is_none_or(|row| row.enabled); let source = match definition.source.as_str() { @@ -1868,29 +1914,41 @@ fn assistant_projection_for_definition( "generated" => AssistantSource::Bare, _ => AssistantSource::User, }; - let effective_backend = state - .and_then(|row| row.agent_backend_override.as_deref()) - .unwrap_or(definition.agent_backend.as_str()); + let effective_agent_id = effective_agent_id_for_definition(definition, state); + let fallback_runtime_backend = resolved_runtime_backend.unwrap_or(effective_agent_id); // An agent row identifies its runtime key by `backend` for vendor ACP // agents, but aionrs (the built-in Rust agent) has a NULL `backend` and is // keyed by its `agent_type` ("aionrs") instead. Match on either so aionrs // assistants resolve to the aionrs row rather than falling back to Missing. let row_matches_backend = |row: &&AgentManagementRow| { - row.backend.as_deref() == Some(effective_backend) || row.agent_type.serde_name() == effective_backend + row.backend.as_deref() == Some(effective_agent_id) + || row.agent_type.serde_name() == effective_agent_id + || row.backend.as_deref() == Some(fallback_runtime_backend) + || row.agent_type.serde_name() == fallback_runtime_backend }; let agent_row = if matches!(source, AssistantSource::Bare) { - definition - .source_ref - .as_deref() - .and_then(|source_ref| agent_rows.iter().find(|row| row.id == source_ref)) + agent_rows.iter().find(|row| row.id == effective_agent_id).or_else(|| { + definition + .source_ref + .as_deref() + .and_then(|source_ref| agent_rows.iter().find(|row| row.id == source_ref)) + }) } else { agent_rows .iter() - .find(|row| row_matches_backend(row) && row.agent_source != AgentSource::Custom) + .find(|row| row.id == effective_agent_id) + .or_else(|| { + agent_rows + .iter() + .find(|row| row_matches_backend(row) && row.agent_source != AgentSource::Custom) + }) .or_else(|| agent_rows.iter().find(row_matches_backend)) }; + let runtime_backend = agent_row + .map(runtime_backend_for_management_row) + .unwrap_or_else(|| fallback_runtime_backend.to_owned()); let agent_status = agent_row .map(|row| row.status) @@ -1920,6 +1978,7 @@ fn assistant_projection_for_definition( }; AssistantRuntimeProjection { + runtime_backend, agent_status, agent_status_message, team_selectable: enabled @@ -1929,6 +1988,24 @@ fn assistant_projection_for_definition( } } +fn effective_agent_id_for_definition<'a>( + definition: &'a AssistantDefinitionRow, + state: Option<&'a AssistantOverlayRow>, +) -> &'a str { + state + .and_then(|row| row.agent_id_override.as_deref()) + .unwrap_or(definition.agent_id.as_str()) +} + +fn runtime_backend_for_management_row(row: &AgentManagementRow) -> String { + row.backend + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| row.agent_type.serde_name()) + .to_owned() +} + // --------------------------------------------------------------------------- // Serialization helpers // --------------------------------------------------------------------------- @@ -2494,7 +2571,7 @@ mod tests { description_i18n: "{}", avatar_type: "none", avatar_value: None, - agent_backend: "claude", + agent_id: "agent-claude", rule_resource_type: "none", rule_resource_ref: None, rule_inline_content: None, @@ -2518,7 +2595,7 @@ mod tests { definition_id: "asstdef-generated", enabled: true, sort_order: 3, - agent_backend_override: None, + agent_id_override: None, last_used_at: None, }) .await diff --git a/crates/aionui-channel/src/channel_settings.rs b/crates/aionui-channel/src/channel_settings.rs index 846175263..222d45415 100644 --- a/crates/aionui-channel/src/channel_settings.rs +++ b/crates/aionui-channel/src/channel_settings.rs @@ -5,7 +5,10 @@ use aionui_api_types::{ ChannelPlatformSettingsResponse, }; use aionui_common::ProviderWithModel; -use aionui_db::{IAssistantDefinitionRepository, IAssistantOverlayRepository, IClientPreferenceRepository}; +use aionui_db::{ + IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, IClientPreferenceRepository, + resolve_agent_binding_from_rows, +}; use tracing::debug; use crate::error::ChannelError; @@ -20,6 +23,7 @@ const DEFAULT_AGENT_TYPE: &str = "aionrs"; /// - `assistant.{platform}.defaultModel` → JSON `{"id":"provider_id","use_model":"model_name"}` pub struct ChannelSettingsService { pref_repo: Arc, + agent_metadata_repo: Option>, assistant_definition_repo: Option>, assistant_overlay_repo: Option>, } @@ -46,11 +50,17 @@ impl ChannelSettingsService { pub fn new(pref_repo: Arc) -> Self { Self { pref_repo, + agent_metadata_repo: None, assistant_definition_repo: None, assistant_overlay_repo: None, } } + pub fn with_agent_metadata_repo(mut self, agent_metadata_repo: Arc) -> Self { + self.agent_metadata_repo = Some(agent_metadata_repo); + self + } + pub fn with_assistant_repos( mut self, assistant_definition_repo: Arc, @@ -242,11 +252,12 @@ impl ChannelSettingsService { return Ok(None); }; - let agent_backend = overlay_repo + let agent_id = overlay_repo .get(&definition.definition_id) .await? - .and_then(|row| row.agent_backend_override) - .unwrap_or(definition.agent_backend); + .and_then(|row| row.agent_id_override) + .unwrap_or(definition.agent_id); + let agent_backend = self.runtime_backend_for_agent_id(&agent_id).await?; let agent_type = backend_to_agent_type(&agent_backend); let backend = if agent_type == "acp" { Some(agent_backend) } else { None }; @@ -275,10 +286,17 @@ impl ChannelSettingsService { let definitions = definition_repo.list().await?; - Ok(definitions - .into_iter() - .find(|definition| definition.source == "generated" && definition.agent_backend == legacy_backend) - .map(|definition| definition.assistant_key)) + for definition in definitions { + if definition.source != "generated" { + continue; + } + let runtime_backend = self.runtime_backend_for_agent_id(&definition.agent_id).await?; + if runtime_backend == legacy_backend { + return Ok(Some(definition.assistant_key)); + } + } + + Ok(None) } async fn normalize_channel_assistant_setting_for_response( @@ -343,18 +361,47 @@ impl ChannelSettingsService { let definitions = definition_repo.list().await?; let overlays = overlay_repo.list().await?; - let generated_aionrs = definitions.iter().find(|definition| { - definition.source == "generated" && effective_assistant_backend(definition, &overlays) == DEFAULT_AGENT_TYPE - }); - if let Some(definition) = generated_aionrs { + for definition in definitions.iter().filter(|definition| definition.source == "generated") { + if self.effective_assistant_backend(definition, &overlays).await? == DEFAULT_AGENT_TYPE { + return Ok(Some(definition.assistant_key.clone())); + } + } + + let mut any_aionrs = None; + for definition in &definitions { + if self.effective_assistant_backend(definition, &overlays).await? == DEFAULT_AGENT_TYPE { + any_aionrs = Some(definition); + break; + } + } + if let Some(definition) = any_aionrs { return Ok(Some(definition.assistant_key.clone())); } - let any_aionrs = definitions + Ok(None) + } + + async fn effective_assistant_backend( + &self, + definition: &aionui_db::models::AssistantDefinitionRow, + overlays: &[aionui_db::models::AssistantOverlayRow], + ) -> Result { + let agent_id = overlays .iter() - .find(|definition| effective_assistant_backend(definition, &overlays) == DEFAULT_AGENT_TYPE); + .find(|overlay| overlay.definition_id == definition.definition_id) + .and_then(|overlay| overlay.agent_id_override.as_deref()) + .unwrap_or(definition.agent_id.as_str()); + self.runtime_backend_for_agent_id(agent_id).await + } - Ok(any_aionrs.map(|definition| definition.assistant_key.clone())) + async fn runtime_backend_for_agent_id(&self, agent_id: &str) -> Result { + let Some(agent_metadata_repo) = self.agent_metadata_repo.as_ref() else { + return Ok(agent_id.to_owned()); + }; + let rows = agent_metadata_repo.list_all().await?; + Ok(resolve_agent_binding_from_rows(&rows, agent_id) + .map(|binding| binding.runtime_backend) + .unwrap_or_else(|| agent_id.to_owned())) } } @@ -407,18 +454,6 @@ fn normalize_channel_assistant_setting_for_write( } } -fn effective_assistant_backend( - definition: &aionui_db::models::AssistantDefinitionRow, - overlays: &[aionui_db::models::AssistantOverlayRow], -) -> String { - overlays - .iter() - .find(|overlay| overlay.definition_id == definition.definition_id) - .and_then(|overlay| overlay.agent_backend_override.as_deref()) - .unwrap_or(definition.agent_backend.as_str()) - .to_owned() -} - fn parse_channel_model_setting(value: &str) -> Option { let parsed: serde_json::Value = serde_json::from_str(value).ok()?; let id = parsed["id"].as_str()?.to_owned(); @@ -600,7 +635,7 @@ mod tests { } } - fn make_definition(assistant_key: &str, agent_backend: &str) -> AssistantDefinitionRow { + fn make_definition(assistant_key: &str, agent_id: &str) -> AssistantDefinitionRow { AssistantDefinitionRow { definition_id: format!("def-{assistant_key}"), assistant_key: assistant_key.to_owned(), @@ -615,7 +650,7 @@ mod tests { description_i18n: "{}".to_owned(), avatar_type: "emoji".to_owned(), avatar_value: None, - agent_backend: agent_backend.to_owned(), + agent_id: agent_id.to_owned(), rule_resource_type: "inline".to_owned(), rule_resource_ref: None, rule_inline_content: None, @@ -637,12 +672,12 @@ mod tests { } } - fn make_overlay(definition_id: &str, agent_backend_override: &str) -> AssistantOverlayRow { + fn make_overlay(definition_id: &str, agent_id_override: &str) -> AssistantOverlayRow { AssistantOverlayRow { definition_id: definition_id.to_owned(), enabled: true, sort_order: 0, - agent_backend_override: Some(agent_backend_override.to_owned()), + agent_id_override: Some(agent_id_override.to_owned()), last_used_at: None, created_at: 0, updated_at: 0, diff --git a/crates/aionui-channel/tests/message_service_integration.rs b/crates/aionui-channel/tests/message_service_integration.rs index cdc998195..93ebeb760 100644 --- a/crates/aionui-channel/tests/message_service_integration.rs +++ b/crates/aionui-channel/tests/message_service_integration.rs @@ -184,7 +184,7 @@ impl IWorkerTaskManager for RecordingTaskManager { fn bare_assistant_definition_params<'a>( definition_id: &'a str, assistant_key: &'a str, - agent_backend: &'a str, + agent_id: &'a str, ) -> UpsertAssistantDefinitionParams<'a> { UpsertAssistantDefinitionParams { definition_id, @@ -200,7 +200,7 @@ fn bare_assistant_definition_params<'a>( description_i18n: "{}", avatar_type: "emoji", avatar_value: Some("🤖"), - agent_backend, + agent_id, rule_resource_type: "inline", rule_resource_ref: None, rule_inline_content: Some(""), @@ -357,7 +357,7 @@ async fn send_to_agent_persists_assistant_snapshot_for_channel_bound_assistant() assert_eq!(session_row.agent_backend, "claude"); assert!(!session_row.agent_id.is_empty()); assert_eq!(snapshot.assistant_key, "bare-claude"); - assert_eq!(snapshot.agent_backend, "claude"); + assert_eq!(snapshot.agent_id, "2d23ff1c"); assert_eq!(conversation.name, "Claude"); } @@ -487,7 +487,7 @@ async fn send_to_agent_without_saved_binding_defaults_to_bare_aionrs_assistant() let conversation = conversation_repo.get(&result.conversation_id).await.unwrap().unwrap(); assert_eq!(snapshot.assistant_key, "bare-aionrs"); - assert_eq!(snapshot.agent_backend, "aionrs"); + assert_eq!(snapshot.agent_id, "632f31d2"); assert_eq!(conversation.r#type, AgentType::Aionrs.serde_name()); assert_eq!(conversation.name, "tg-aionrs-70880480"); } diff --git a/crates/aionui-conversation/src/convert.rs b/crates/aionui-conversation/src/convert.rs index a2905aa78..eff7e263d 100644 --- a/crates/aionui-conversation/src/convert.rs +++ b/crates/aionui-conversation/src/convert.rs @@ -84,6 +84,7 @@ pub fn row_to_response_with_extra( pub fn snapshot_to_assistant_identity( snapshot: &ConversationAssistantSnapshotRow, + runtime_backend: &str, ) -> ConversationAssistantIdentityResponse { let avatar = match snapshot.assistant_avatar_type.as_str() { "builtin_asset" | "user_asset" => format!("/api/assistants/{}/avatar", snapshot.assistant_key), @@ -95,7 +96,7 @@ pub fn snapshot_to_assistant_identity( source: snapshot.assistant_source.clone(), name: snapshot.assistant_name.clone(), avatar, - backend: snapshot.agent_backend.clone(), + backend: runtime_backend.to_owned(), } } diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 3059ecb9a..e9f4e0944 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -26,10 +26,10 @@ use aionui_common::{ }; use aionui_db::models::{ConversationRow, MessageRow}; use aionui_db::{ - ConversationFilters, ConversationRowUpdate, CreateAcpSessionParams, IAcpSessionRepository, + AgentBindingResolution, ConversationFilters, ConversationRowUpdate, CreateAcpSessionParams, IAcpSessionRepository, IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, IAssistantPreferenceRepository, IConversationRepository, IMcpServerRepository, SaveRuntimeStateParams, SortOrder, - UpsertConversationAssistantSnapshotParams, + UpsertConversationAssistantSnapshotParams, resolve_agent_binding_from_rows, }; use aionui_extension::AssistantRuleDispatcher; use aionui_mcp::{AcpMcpCapabilities, parse_acp_mcp_capabilities}; @@ -144,11 +144,12 @@ struct AssistantSnapshot { avatar_type: String, #[serde(default)] avatar: Option, - #[serde(default)] - agent_id: Option, - #[serde(default)] - agent_source: Option, - agent_backend: String, + #[serde(default, deserialize_with = "deserialize_string_or_null")] + agent_id: String, + #[serde(default, deserialize_with = "deserialize_string_or_null")] + agent_source: String, + #[serde(default, alias = "agent_backend", deserialize_with = "deserialize_string_or_null")] + runtime_backend: String, rules: AssistantSnapshotRules, #[serde(default)] default_modes: AssistantSnapshotDefaultModes, @@ -156,6 +157,13 @@ struct AssistantSnapshot { created_at: i64, } +fn deserialize_string_or_null<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok( as serde::Deserialize>::deserialize(deserializer)?.unwrap_or_default()) +} + #[derive(Debug, Clone, Copy)] struct AssistantEffectiveDefaultModes<'a> { model: &'a str, @@ -211,14 +219,14 @@ fn resolve_create_agent_type( assistant_snapshot: Option<&AssistantSnapshot>, ) -> Result { if let Some(snapshot) = assistant_snapshot { - let derived = parse_supported_agent_type_from_backend(snapshot.agent_backend.trim())?; + let derived = parse_supported_agent_type_from_backend(snapshot.runtime_backend.trim())?; if let Some(explicit) = explicit_type && explicit != derived { warn!( explicit_type = explicit.serde_name(), derived_type = derived.serde_name(), - backend = snapshot.agent_backend, + backend = snapshot.runtime_backend, assistant_id = snapshot.assistant_id, "assistant-backed create request carried a mismatched explicit type; using assistant-derived type" ); @@ -607,7 +615,12 @@ impl ConversationService { } if let Some(snapshot) = self.conversation_repo.get_assistant_snapshot(&response.id).await? { - response.assistant = Some(snapshot_to_assistant_identity(&snapshot)); + let runtime_backend = self + .resolve_assistant_agent_binding(&snapshot.agent_id) + .await? + .map(|binding| binding.runtime_backend) + .unwrap_or_else(|| snapshot.agent_id.clone()); + response.assistant = Some(snapshot_to_assistant_identity(&snapshot, &runtime_backend)); } Ok(()) @@ -710,7 +723,7 @@ impl ConversationService { let assistant_backend = assistant_snapshot .as_ref() - .map(|snapshot| snapshot.agent_backend.clone()) + .map(|snapshot| snapshot.runtime_backend.clone()) .filter(|backend| !backend.is_empty()); let effective_backend = assistant_backend.or_else(|| { extra @@ -762,25 +775,24 @@ impl ConversationService { // helpers. Persisting them here keeps one source of truth — // the assistant — while preserving the contract those // downstreams already depend on. - if !snapshot.agent_backend.is_empty() { + if !snapshot.runtime_backend.is_empty() { obj.insert( "backend".to_owned(), - serde_json::Value::String(snapshot.agent_backend.clone()), + serde_json::Value::String(snapshot.runtime_backend.clone()), ); } - if let Some(agent_id) = snapshot.agent_id.as_ref() - && !agent_id.is_empty() - { - obj.insert("agent_id".to_owned(), serde_json::Value::String(agent_id.clone())); + if !snapshot.agent_id.is_empty() { + obj.insert( + "agent_id".to_owned(), + serde_json::Value::String(snapshot.agent_id.clone()), + ); } else { obj.remove("agent_id"); } - if let Some(agent_source) = snapshot.agent_source.as_ref() - && !agent_source.is_empty() - { + if !snapshot.agent_source.is_empty() { obj.insert( "agent_source".to_owned(), - serde_json::Value::String(agent_source.clone()), + serde_json::Value::String(snapshot.agent_source.clone()), ); } else { obj.remove("agent_source"); @@ -1043,7 +1055,7 @@ impl ConversationService { assistant_name: &snapshot.name, assistant_avatar_type: &snapshot.avatar_type, assistant_avatar_value: snapshot.avatar.as_deref(), - agent_backend: &snapshot.agent_backend, + agent_id: &snapshot.agent_id, rules_content: &snapshot.rules.content, default_model_mode: &snapshot.default_modes.model, resolved_model_id: snapshot.resolved_defaults.model.as_deref(), @@ -1081,7 +1093,7 @@ impl ConversationService { "builtin_asset" | "user_asset" => format!("/api/assistants/{}/avatar", snapshot.assistant_id), _ => snapshot.avatar.clone().unwrap_or_default(), }, - backend: snapshot.agent_backend.clone(), + backend: snapshot.runtime_backend.clone(), }); } @@ -1108,7 +1120,7 @@ impl ConversationService { // payloads may only carry `backend`, so we resolve defensively. let agent_id_from_extra = extra.get("agent_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); let backend = assistant_snapshot - .map(|snapshot| snapshot.agent_backend.as_str()) + .map(|snapshot| snapshot.runtime_backend.as_str()) .filter(|value| !value.is_empty()) .or_else(|| { extra @@ -1118,7 +1130,7 @@ impl ConversationService { }) .unwrap_or_default(); let agent_source = assistant_snapshot - .and_then(|snapshot| snapshot.agent_source.as_deref()) + .map(|snapshot| snapshot.agent_source.as_str()) .filter(|value| !value.is_empty()) .or_else(|| extra.get("agent_source").and_then(|v| v.as_str())) .unwrap_or("builtin"); @@ -1130,7 +1142,7 @@ impl ConversationService { // explicitly — custom/extension rows have no unique lookup key // from `(backend, agent_source)` alone. let resolved_agent_id = match assistant_snapshot - .and_then(|snapshot| snapshot.agent_id.as_deref()) + .map(|snapshot| snapshot.agent_id.as_str()) .filter(|id| !id.is_empty()) .or(agent_id_from_extra) { @@ -1183,6 +1195,18 @@ impl ConversationService { Ok(()) } + async fn resolve_assistant_agent_binding( + &self, + value: &str, + ) -> Result, ConversationError> { + let rows = self + .agent_metadata_repo + .list_all() + .await + .map_err(|e| ConversationError::internal(format!("agent_metadata lookup failed: {e}")))?; + Ok(resolve_agent_binding_from_rows(&rows, value)) + } + async fn resolve_assistant_snapshot( &self, assistant_id: &str, @@ -1285,22 +1309,11 @@ impl ConversationService { .and_then(serde_json::Value::as_str) .or_else(|| extra.get("preset_rules").and_then(serde_json::Value::as_str)) .unwrap_or_default(); - let agent_backend = state + let effective_agent_id = state .as_ref() - .and_then(|row| row.agent_backend_override.clone()) - .unwrap_or_else(|| definition.agent_backend.clone()); - let generated_agent = if definition.source == "generated" { - match definition.source_ref.as_deref() { - Some(agent_id) => self - .agent_metadata_repo - .get(agent_id) - .await - .map_err(|e| ConversationError::internal(format!("agent_metadata lookup failed: {e}")))?, - None => None, - } - } else { - None - }; + .and_then(|row| row.agent_id_override.clone()) + .unwrap_or_else(|| definition.agent_id.clone()); + let agent_binding = self.resolve_assistant_agent_binding(&effective_agent_id).await?; Ok(Some(AssistantSnapshot { assistant_definition_id: definition.definition_id, @@ -1309,9 +1322,17 @@ impl ConversationService { name: definition.name, avatar_type: definition.avatar_type, avatar: definition.avatar_value, - agent_id: generated_agent.as_ref().map(|row| row.id.clone()), - agent_source: generated_agent.as_ref().map(|row| row.agent_source.clone()), - agent_backend, + agent_id: agent_binding + .as_ref() + .map(|binding| binding.agent_id.clone()) + .unwrap_or(effective_agent_id.clone()), + agent_source: agent_binding + .as_ref() + .map(|binding| binding.agent_source.clone()) + .unwrap_or_else(|| "builtin".to_owned()), + runtime_backend: agent_binding + .map(|binding| binding.runtime_backend) + .unwrap_or(effective_agent_id), rules: AssistantSnapshotRules { content: if rules_content.is_empty() { fallback_rules.to_owned() @@ -1430,7 +1451,7 @@ impl ConversationService { assistant_name: &snapshot.assistant_name, assistant_avatar_type: &snapshot.assistant_avatar_type, assistant_avatar_value: snapshot.assistant_avatar_value.as_deref(), - agent_backend: &snapshot.agent_backend, + agent_id: &snapshot.agent_id, rules_content: &snapshot.rules_content, default_model_mode: &snapshot.default_model_mode, resolved_model_id: updates.model.or(snapshot.resolved_model_id.as_deref()), diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 8f56e7bec..1bb7cdbb3 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -333,7 +333,7 @@ impl IConversationRepository for MockRepo { assistant_name: params.assistant_name.to_owned(), assistant_avatar_type: params.assistant_avatar_type.to_owned(), assistant_avatar_value: params.assistant_avatar_value.map(ToOwned::to_owned), - agent_backend: params.agent_backend.to_owned(), + agent_id: params.agent_id.to_owned(), rules_content: params.rules_content.to_owned(), default_model_mode: params.default_model_mode.to_owned(), resolved_model_id: params.resolved_model_id.map(ToOwned::to_owned), @@ -568,17 +568,68 @@ impl IConversationRepository for MockRepo { // ── Helpers ──────────────────────────────────────────────────────── -/// Stub repository for tests — every lookup returns `None` so the -/// service falls back to `AgentType::native_skills_dirs()` paths. +/// Stub repository for tests. Builtin rows mirror the ids used by the +/// migration seed so assistant id-resolution tests exercise the same +/// boundary as the SQLite repository. struct StubAgentMetadataRepo; +fn stub_agent_metadata_rows() -> Vec { + [ + ("2d23ff1c", Some("claude"), "acp", "Claude Code", 100), + ("8e1acf31", Some("codex"), "acp", "Codex CLI", 110), + ("cc126dd5", Some("gemini"), "acp", "Gemini CLI", 120), + ("632f31d2", None, "aionrs", "Aion CLI", 200), + ] + .into_iter() + .map(|(id, backend, agent_type, name, sort_order)| AgentMetadataRow { + id: id.to_owned(), + icon: None, + name: name.to_owned(), + name_i18n: None, + description: None, + description_i18n: None, + backend: backend.map(ToOwned::to_owned), + agent_type: agent_type.to_owned(), + agent_source: "builtin".to_owned(), + agent_source_info: None, + enabled: true, + command: backend.map(ToOwned::to_owned), + args: Some("[]".to_owned()), + env: Some("[]".to_owned()), + native_skills_dirs: None, + behavior_policy: None, + yolo_id: None, + agent_capabilities: None, + auth_methods: None, + config_options: None, + available_modes: None, + available_models: None, + available_commands: None, + sort_order, + last_check_status: None, + last_check_kind: None, + last_check_error_code: None, + last_check_error_message: None, + last_check_guidance: None, + last_check_latency_ms: None, + last_check_at: None, + last_success_at: None, + last_failure_at: None, + command_override: None, + env_override: None, + created_at: 1, + updated_at: 1, + }) + .collect() +} + #[async_trait::async_trait] impl IAgentMetadataRepository for StubAgentMetadataRepo { async fn list_all(&self) -> Result, DbError> { - Ok(Vec::new()) + Ok(stub_agent_metadata_rows()) } - async fn get(&self, _id: &str) -> Result, DbError> { - Ok(None) + async fn get(&self, id: &str) -> Result, DbError> { + Ok(stub_agent_metadata_rows().into_iter().find(|row| row.id == id)) } async fn find_by_source_and_name( &self, @@ -587,8 +638,10 @@ impl IAgentMetadataRepository for StubAgentMetadataRepo { ) -> Result, DbError> { Ok(None) } - async fn find_builtin_by_backend(&self, _backend: &str) -> Result, DbError> { - Ok(None) + async fn find_builtin_by_backend(&self, backend: &str) -> Result, DbError> { + Ok(stub_agent_metadata_rows() + .into_iter() + .find(|row| row.agent_source == "builtin" && row.backend.as_deref() == Some(backend))) } async fn upsert(&self, _params: &UpsertAgentMetadataParams<'_>) -> Result { Err(DbError::Init("stub".into())) @@ -889,7 +942,7 @@ async fn upsert_test_assistant_definition( repo: &SqliteAssistantDefinitionRepository, definition_id: &str, assistant_key: &str, - agent_backend: &str, + agent_id: &str, default_model_mode: &str, default_permission_mode: &str, ) { @@ -907,7 +960,7 @@ async fn upsert_test_assistant_definition( description_i18n: "{}", avatar_type: "emoji", avatar_value: Some("🤖"), - agent_backend, + agent_id, rule_resource_type: "builtin_asset", rule_resource_ref: Some(assistant_key), rule_inline_content: None, @@ -1172,7 +1225,7 @@ async fn create_derives_aionrs_type_from_assistant_backend_when_type_is_missing( definition_id: "asstdef_aionrs_missing_type", enabled: true, sort_order: 0, - agent_backend_override: None, + agent_id_override: None, last_used_at: None, }) .await @@ -1223,7 +1276,7 @@ async fn create_derives_acp_type_from_assistant_backend_when_type_is_missing() { definition_id: "asstdef_acp_missing_type", enabled: true, sort_order: 0, - agent_backend_override: None, + agent_id_override: None, last_used_at: None, }) .await @@ -2811,7 +2864,7 @@ async fn set_config_option_persists_runtime_model_into_assistant_preference_when definition_id: "asstdef_acp_auto", enabled: true, sort_order: 0, - agent_backend_override: None, + agent_id_override: None, last_used_at: None, }) .await @@ -2901,7 +2954,7 @@ async fn set_config_option_skips_preference_write_back_when_default_mode_is_fixe definition_id: "asstdef_acp_fixed", enabled: true, sort_order: 0, - agent_backend_override: None, + agent_id_override: None, last_used_at: None, }) .await @@ -2971,7 +3024,7 @@ async fn set_config_option_command_ack_does_not_persist_assistant_preference() { definition_id: "asstdef_acp_ack", enabled: true, sort_order: 0, - agent_backend_override: None, + agent_id_override: None, last_used_at: None, }) .await @@ -3034,7 +3087,7 @@ async fn update_aionrs_model_updates_assistant_preference_only_when_snapshot_mod definition_id: "asstdef_aionrs_auto", enabled: true, sort_order: 0, - agent_backend_override: None, + agent_id_override: None, last_used_at: None, }) .await @@ -3095,7 +3148,7 @@ async fn update_aionrs_model_updates_assistant_preference_only_when_snapshot_mod definition_id: "asstdef_aionrs_fixed", enabled: true, sort_order: 0, - agent_backend_override: None, + agent_id_override: None, last_used_at: None, }) .await @@ -4616,7 +4669,7 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { description_i18n: "{}", avatar_type: "emoji", avatar_value: Some("🤖"), - agent_backend: "claude", + agent_id: "claude", rule_resource_type: "builtin_asset", rule_resource_ref: Some("preset-1"), rule_inline_content: None, @@ -4640,7 +4693,7 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { definition_id: "asstdef_preset_1", enabled: true, sort_order: 0, - agent_backend_override: Some("codex"), + agent_id_override: Some("codex"), last_used_at: None, }) .await @@ -4688,8 +4741,8 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { }) ); assert!(resp.extra.get("assistant_id").is_none()); - assert!(resp.extra.get("agent_id").is_none()); - assert!(resp.extra.get("agent_source").is_none()); + assert_eq!(resp.extra["agent_id"], json!("8e1acf31")); + assert_eq!(resp.extra["agent_source"], json!("builtin")); assert!(resp.extra.get("preset_assistant_id").is_none()); assert!(resp.extra.get("preset_context").is_none()); assert!(resp.extra.get("preset_rules").is_none()); @@ -4702,7 +4755,7 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { let snapshot = repo.get_assistant_snapshot(&resp.id).await.unwrap().unwrap(); assert_eq!(snapshot.assistant_definition_id, "asstdef_preset_1"); assert_eq!(snapshot.assistant_key, "preset-1"); - assert_eq!(snapshot.agent_backend, "codex"); + assert_eq!(snapshot.agent_id, "8e1acf31"); assert_eq!(snapshot.rules_content, "assistant rule body"); assert_eq!(snapshot.default_model_mode, "auto"); assert_eq!(snapshot.resolved_model_id.as_deref(), Some("new-model")); @@ -4778,7 +4831,7 @@ async fn create_prefers_assistant_snapshot_over_legacy_runtime_seed_fields() { description_i18n: "{}", avatar_type: "emoji", avatar_value: Some("🤖"), - agent_backend: "claude", + agent_id: "claude", rule_resource_type: "builtin_asset", rule_resource_ref: Some("preset-1"), rule_inline_content: None, @@ -4802,7 +4855,7 @@ async fn create_prefers_assistant_snapshot_over_legacy_runtime_seed_fields() { definition_id: "asstdef_preset_legacy_seed", enabled: true, sort_order: 0, - agent_backend_override: Some("codex"), + agent_id_override: Some("codex"), last_used_at: None, }) .await @@ -4845,7 +4898,7 @@ async fn create_prefers_assistant_snapshot_over_legacy_runtime_seed_fields() { assert_eq!(resp.extra["current_mode_id"], json!("workspace-write")); let snapshot = repo.get_assistant_snapshot(&resp.id).await.unwrap().unwrap(); - assert_eq!(snapshot.agent_backend, "codex"); + assert_eq!(snapshot.agent_id, "8e1acf31"); assert_eq!(snapshot.resolved_model_id.as_deref(), Some("override-model")); assert_eq!(snapshot.resolved_permission_value.as_deref(), Some("workspace-write")); } @@ -4874,7 +4927,7 @@ async fn create_prefers_snapshot_runtime_identity_over_legacy_extra_identity() { definition_id: "asstdef_snapshot_identity", enabled: true, sort_order: 0, - agent_backend_override: None, + agent_id_override: None, last_used_at: None, }) .await @@ -4903,13 +4956,13 @@ async fn create_prefers_snapshot_runtime_identity_over_legacy_extra_identity() { Some("codex") ); assert_eq!(resp.extra["backend"], json!("codex")); - assert!(resp.extra.get("agent_id").is_none()); + assert_eq!(resp.extra["agent_id"], json!("8e1acf31")); let create_calls = acp_repo.create_calls(); assert_eq!(create_calls.len(), 1); assert_eq!(create_calls[0].agent_backend, "codex"); assert_eq!(create_calls[0].agent_source, "builtin"); - assert_ne!(create_calls[0].agent_id, "legacy-custom-agent"); + assert_eq!(create_calls[0].agent_id, "8e1acf31"); } #[tokio::test] @@ -4939,7 +4992,7 @@ async fn create_does_not_overwrite_preferences_for_fixed_skills_and_mcps() { description_i18n: "{}", avatar_type: "emoji", avatar_value: Some("🤖"), - agent_backend: "claude", + agent_id: "claude", rule_resource_type: "builtin_asset", rule_resource_ref: Some("preset-fixed"), rule_inline_content: None, @@ -4963,7 +5016,7 @@ async fn create_does_not_overwrite_preferences_for_fixed_skills_and_mcps() { definition_id: "asstdef_preset_fixed", enabled: true, sort_order: 0, - agent_backend_override: Some("codex"), + agent_id_override: Some("codex"), last_used_at: None, }) .await @@ -5037,7 +5090,7 @@ async fn create_with_auto_builtin_defaults_without_preferences_keeps_snapshot_va description_i18n: "{}", avatar_type: "emoji", avatar_value: Some("🤖"), - agent_backend: "claude", + agent_id: "claude", rule_resource_type: "builtin_asset", rule_resource_ref: Some("preset-auto"), rule_inline_content: None, @@ -5061,7 +5114,7 @@ async fn create_with_auto_builtin_defaults_without_preferences_keeps_snapshot_va definition_id: "asstdef_preset_auto", enabled: true, sort_order: 0, - agent_backend_override: Some("codex"), + agent_id_override: Some("codex"), last_used_at: None, }) .await diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index a1dd864f7..34f7f611e 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -9,7 +9,10 @@ use aionui_api_types::{ use aionui_common::{ AgentType, WorkspacePathValidationError, generate_prefixed_id, now_ms, validate_workspace_path_availability, }; -use aionui_db::{IAssistantDefinitionRepository, IAssistantOverlayRepository, ICronRepository, UpdateCronJobParams}; +use aionui_db::{ + IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, ICronRepository, + UpdateCronJobParams, resolve_agent_binding_from_rows, +}; use tracing::{error, info, warn}; use crate::events::CronEventEmitter; @@ -40,6 +43,7 @@ const DEPRECATED_AGENT_TYPE_MESSAGE: &str = "This agent type is no longer suppor #[derive(Clone)] pub struct CronService { repo: Arc, + agent_metadata_repo: Arc, assistant_definition_repo: Arc, assistant_overlay_repo: Arc, scheduler: Arc, @@ -51,6 +55,7 @@ pub struct CronService { impl CronService { pub fn new( repo: Arc, + agent_metadata_repo: Arc, assistant_definition_repo: Arc, assistant_overlay_repo: Arc, scheduler: Arc, @@ -60,6 +65,7 @@ impl CronService { ) -> Self { Self { repo, + agent_metadata_repo, assistant_definition_repo, assistant_overlay_repo, scheduler, @@ -457,12 +463,13 @@ impl CronService { .await? .ok_or_else(|| CronError::InvalidAgentConfig(format!("assistant '{assistant_id}' not found")))?; let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; - let effective_backend = overlay + let effective_agent_id = overlay .as_ref() - .and_then(|item| item.agent_backend_override.as_deref()) - .unwrap_or(definition.agent_backend.as_str()); + .and_then(|item| item.agent_id_override.as_deref()) + .unwrap_or(definition.agent_id.as_str()); + let effective_backend = self.runtime_backend_for_agent_id(effective_agent_id).await?; - Ok(runtime_agent_type_for_backend(effective_backend).to_owned()) + Ok(runtime_agent_type_for_backend(&effective_backend).to_owned()) } async fn bind_existing_conversation_if_needed(&self, job: &CronJob) { @@ -895,10 +902,20 @@ impl CronService { .map(|snapshot| snapshot.assistant_key.trim().to_owned()) .filter(|value| !value.is_empty()); let assistant_id = snapshot_assistant_id.or(extra_assistant_id); - let snapshot_backend = assistant_snapshot - .as_ref() - .map(|snapshot| snapshot.agent_backend.trim().to_owned()) - .filter(|value| !value.is_empty()); + let snapshot_backend = match assistant_snapshot.as_ref() { + Some(snapshot) => match self.runtime_backend_for_agent_id(snapshot.agent_id.trim()).await { + Ok(value) => Some(value).filter(|value| !value.is_empty()), + Err(err) => { + warn!( + conversation_id = %row.id, + error = %err, + "Failed to resolve assistant snapshot agent id for cron agent config" + ); + None + } + }, + None => None, + }; let assistant_backend = snapshot_backend.clone().or(self .resolve_assistant_backend(assistant_id.as_deref()) .await @@ -1027,14 +1044,19 @@ impl CronService { return Ok(None); }; let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; + let effective_agent_id = overlay + .as_ref() + .and_then(|item| item.agent_id_override.as_deref()) + .unwrap_or(definition.agent_id.as_str()); - Ok(Some( - overlay - .as_ref() - .and_then(|item| item.agent_backend_override.as_deref()) - .unwrap_or(definition.agent_backend.as_str()) - .to_owned(), - )) + Ok(Some(self.runtime_backend_for_agent_id(effective_agent_id).await?)) + } + + async fn runtime_backend_for_agent_id(&self, agent_id: &str) -> Result { + let rows = self.agent_metadata_repo.list_all().await?; + Ok(resolve_agent_binding_from_rows(&rows, agent_id) + .map(|binding| binding.runtime_backend) + .unwrap_or_else(|| agent_id.to_owned())) } } diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index bf4ab0872..abea1c5df 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -716,7 +716,7 @@ async fn setup_with_conv_repo() -> ( Arc::clone(&agent_metadata_repo), acp_session_repo, )); - let agent_registry = AgentRegistry::new(agent_metadata_repo); + let agent_registry = AgentRegistry::new(agent_metadata_repo.clone()); agent_registry.hydrate().await.unwrap(); let executor = Arc::new(JobExecutor::new( task_manager, @@ -733,6 +733,7 @@ async fn setup_with_conv_repo() -> ( let emitter = CronEventEmitter::new(bc.clone() as Arc); let svc = CronService::new( cron_repo.clone(), + agent_metadata_repo, assistant_definition_repo.clone(), assistant_overlay_repo.clone(), scheduler, @@ -811,7 +812,7 @@ async fn setup_with_assistant_repos() -> ( Arc::clone(&agent_metadata_repo), acp_session_repo, )); - let agent_registry = AgentRegistry::new(agent_metadata_repo); + let agent_registry = AgentRegistry::new(agent_metadata_repo.clone()); agent_registry.hydrate().await.unwrap(); let executor = Arc::new(JobExecutor::new( task_manager, @@ -827,6 +828,7 @@ async fn setup_with_assistant_repos() -> ( let emitter = CronEventEmitter::new(bc.clone() as Arc); let svc = CronService::new( cron_repo.clone(), + agent_metadata_repo, assistant_definition_repo.clone(), assistant_overlay_repo.clone(), scheduler, @@ -884,6 +886,7 @@ async fn seed_assistant_definition( assistant_key: &str, agent_backend: &str, ) { + let agent_id = seeded_agent_id(agent_backend); repo.upsert(&UpsertAssistantDefinitionParams { definition_id, assistant_key, @@ -898,7 +901,7 @@ async fn seed_assistant_definition( description_i18n: "{}", avatar_type: "emoji", avatar_value: Some("🤖"), - agent_backend, + agent_id, rule_resource_type: "inline", rule_resource_ref: None, rule_inline_content: None, @@ -924,17 +927,28 @@ async fn seed_assistant_overlay( definition_id: &str, agent_backend_override: Option<&str>, ) { + let agent_id_override = agent_backend_override.map(seeded_agent_id); repo.upsert(&UpsertAssistantOverlayParams { definition_id, enabled: true, sort_order: 0, - agent_backend_override, + agent_id_override, last_used_at: None, }) .await .unwrap(); } +fn seeded_agent_id(value: &str) -> &str { + match value { + "claude" => "2d23ff1c", + "codex" => "8e1acf31", + "gemini" => "cc126dd5", + "aionrs" => "632f31d2", + other => other, + } +} + fn every_60s() -> CronScheduleDto { CronScheduleDto::Every { every_ms: 60000, @@ -1930,7 +1944,7 @@ async fn icron_service_create_job_inherits_assistant_snapshot_identity() { assistant_name: "Snapshot Assistant".into(), assistant_avatar_type: "emoji".into(), assistant_avatar_value: Some("S".into()), - agent_backend: "codex".into(), + agent_id: "8e1acf31".into(), rules_content: String::new(), default_model_mode: "default".into(), resolved_model_id: Some("gpt-5.1".into()), diff --git a/crates/aionui-db/migrations/013_agent_connection_snapshot.sql b/crates/aionui-db/migrations/013_agent_connection_snapshot.sql index e484eb93b..af8305ea4 100644 --- a/crates/aionui-db/migrations/013_agent_connection_snapshot.sql +++ b/crates/aionui-db/migrations/013_agent_connection_snapshot.sql @@ -18,3 +18,172 @@ ALTER TABLE agent_metadata ADD COLUMN last_failure_at INTEGER; -- as the existing `env` column. ALTER TABLE agent_metadata ADD COLUMN command_override TEXT; ALTER TABLE agent_metadata ADD COLUMN env_override TEXT; + +-- Assistant/agent unification: assistant storage now binds to the concrete +-- agent catalog row. Runtime backend labels remain compatibility/runtime +-- fields on legacy mirrors, conversation extra, and acp_session. +ALTER TABLE assistant_definitions RENAME COLUMN agent_backend TO agent_id; +ALTER TABLE assistant_overlays RENAME COLUMN agent_backend_override TO agent_id_override; +ALTER TABLE conversation_assistant_snapshots RENAME COLUMN agent_backend TO agent_id; + +DROP INDEX IF EXISTS idx_assistant_definitions_agent_backend; + +UPDATE assistant_definitions +SET agent_id = COALESCE( + ( + SELECT am.id + FROM agent_metadata am + WHERE assistant_definitions.source = 'generated' + AND assistant_definitions.source_ref IS NOT NULL + AND am.id = assistant_definitions.source_ref + LIMIT 1 + ), + ( + SELECT am.id + FROM agent_metadata am + WHERE am.id = assistant_definitions.agent_id + ORDER BY + CASE am.agent_source + WHEN 'builtin' THEN 0 + WHEN 'internal' THEN 1 + ELSE 2 + END, + am.sort_order ASC, + am.name ASC + LIMIT 1 + ), + ( + SELECT am.id + FROM agent_metadata am + WHERE am.backend = assistant_definitions.agent_id + ORDER BY + CASE am.agent_source + WHEN 'builtin' THEN 0 + WHEN 'internal' THEN 1 + ELSE 2 + END, + am.sort_order ASC, + am.name ASC + LIMIT 1 + ), + ( + SELECT am.id + FROM agent_metadata am + WHERE am.agent_type = assistant_definitions.agent_id + ORDER BY + CASE am.agent_source + WHEN 'builtin' THEN 0 + WHEN 'internal' THEN 1 + ELSE 2 + END, + am.sort_order ASC, + am.name ASC + LIMIT 1 + ), + assistant_definitions.agent_id +); + +UPDATE assistant_overlays +SET agent_id_override = COALESCE( + ( + SELECT am.id + FROM agent_metadata am + WHERE am.id = assistant_overlays.agent_id_override + ORDER BY + CASE am.agent_source + WHEN 'builtin' THEN 0 + WHEN 'internal' THEN 1 + ELSE 2 + END, + am.sort_order ASC, + am.name ASC + LIMIT 1 + ), + ( + SELECT am.id + FROM agent_metadata am + WHERE am.backend = assistant_overlays.agent_id_override + ORDER BY + CASE am.agent_source + WHEN 'builtin' THEN 0 + WHEN 'internal' THEN 1 + ELSE 2 + END, + am.sort_order ASC, + am.name ASC + LIMIT 1 + ), + ( + SELECT am.id + FROM agent_metadata am + WHERE am.agent_type = assistant_overlays.agent_id_override + ORDER BY + CASE am.agent_source + WHEN 'builtin' THEN 0 + WHEN 'internal' THEN 1 + ELSE 2 + END, + am.sort_order ASC, + am.name ASC + LIMIT 1 + ), + assistant_overlays.agent_id_override +) +WHERE agent_id_override IS NOT NULL; + +UPDATE conversation_assistant_snapshots +SET agent_id = COALESCE( + ( + SELECT am.id + FROM agent_metadata am + WHERE am.id = conversation_assistant_snapshots.agent_id + ORDER BY + CASE am.agent_source + WHEN 'builtin' THEN 0 + WHEN 'internal' THEN 1 + ELSE 2 + END, + am.sort_order ASC, + am.name ASC + LIMIT 1 + ), + ( + SELECT am.id + FROM agent_metadata am + WHERE am.backend = conversation_assistant_snapshots.agent_id + ORDER BY + CASE am.agent_source + WHEN 'builtin' THEN 0 + WHEN 'internal' THEN 1 + ELSE 2 + END, + am.sort_order ASC, + am.name ASC + LIMIT 1 + ), + ( + SELECT am.id + FROM agent_metadata am + WHERE am.agent_type = conversation_assistant_snapshots.agent_id + ORDER BY + CASE am.agent_source + WHEN 'builtin' THEN 0 + WHEN 'internal' THEN 1 + ELSE 2 + END, + am.sort_order ASC, + am.name ASC + LIMIT 1 + ), + conversation_assistant_snapshots.agent_id +); + +CREATE INDEX IF NOT EXISTS idx_assistant_definitions_agent_id + ON assistant_definitions(agent_id); + +CREATE INDEX IF NOT EXISTS idx_assistant_overlays_agent_id_override + ON assistant_overlays(agent_id_override) + WHERE agent_id_override IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_conversation_assistant_snapshots_agent_id + ON conversation_assistant_snapshots(agent_id); diff --git a/crates/aionui-db/src/agent_binding.rs b/crates/aionui-db/src/agent_binding.rs new file mode 100644 index 000000000..36b94a53e --- /dev/null +++ b/crates/aionui-db/src/agent_binding.rs @@ -0,0 +1,68 @@ +use sqlx::SqlitePool; + +use crate::error::DbError; +use crate::models::AgentMetadataRow; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentBindingResolution { + pub agent_id: String, + pub agent_source: String, + pub agent_type: String, + pub runtime_backend: String, +} + +pub fn runtime_backend_for_agent(row: &AgentMetadataRow) -> String { + row.backend + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(row.agent_type.as_str()) + .to_owned() +} + +pub fn binding_resolution_for_agent(row: &AgentMetadataRow) -> AgentBindingResolution { + AgentBindingResolution { + agent_id: row.id.clone(), + agent_source: row.agent_source.clone(), + agent_type: row.agent_type.clone(), + runtime_backend: runtime_backend_for_agent(row), + } +} + +pub fn resolve_agent_binding_from_rows(rows: &[AgentMetadataRow], value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + + rows.iter() + .filter(|row| row.id == value) + .min_by_key(|row| agent_match_rank(row)) + .or_else(|| { + rows.iter() + .filter(|row| row.backend.as_deref() == Some(value)) + .min_by_key(|row| agent_match_rank(row)) + }) + .or_else(|| { + rows.iter() + .filter(|row| row.agent_type == value) + .min_by_key(|row| agent_match_rank(row)) + }) + .map(binding_resolution_for_agent) +} + +pub async fn resolve_agent_binding(pool: &SqlitePool, value: &str) -> Result, DbError> { + let rows = sqlx::query_as::<_, AgentMetadataRow>("SELECT * FROM agent_metadata") + .fetch_all(pool) + .await?; + Ok(resolve_agent_binding_from_rows(&rows, value)) +} + +fn agent_match_rank(row: &AgentMetadataRow) -> (i32, i64, &str) { + let source_rank = match row.agent_source.as_str() { + "builtin" => 0, + "internal" => 1, + _ => 2, + }; + (source_rank, row.sort_order, row.name.as_str()) +} diff --git a/crates/aionui-db/src/lib.rs b/crates/aionui-db/src/lib.rs index c5ae47da5..889fceae9 100644 --- a/crates/aionui-db/src/lib.rs +++ b/crates/aionui-db/src/lib.rs @@ -1,11 +1,16 @@ #![warn(clippy::disallowed_types)] //! SQLite database layer: init, migrations, repository traits, and implementations. +mod agent_binding; mod database; mod error; pub mod models; mod repository; +pub use agent_binding::{ + AgentBindingResolution, binding_resolution_for_agent, resolve_agent_binding, resolve_agent_binding_from_rows, + runtime_backend_for_agent, +}; pub use database::{ Database, DatabaseInitError, init_database, init_database_memory, init_database_staged, maybe_copy_legacy_database, }; diff --git a/crates/aionui-db/src/models/assistant.rs b/crates/aionui-db/src/models/assistant.rs index eeb92cf3d..81c7dac84 100644 --- a/crates/aionui-db/src/models/assistant.rs +++ b/crates/aionui-db/src/models/assistant.rs @@ -58,7 +58,7 @@ pub struct AssistantDefinitionRow { pub description_i18n: String, pub avatar_type: String, pub avatar_value: Option, - pub agent_backend: String, + pub agent_id: String, pub rule_resource_type: String, pub rule_resource_ref: Option, pub rule_inline_content: Option, @@ -85,7 +85,7 @@ pub struct AssistantOverlayRow { pub definition_id: String, pub enabled: bool, pub sort_order: i32, - pub agent_backend_override: Option, + pub agent_id_override: Option, pub last_used_at: Option, pub created_at: TimestampMs, pub updated_at: TimestampMs, @@ -174,7 +174,7 @@ pub struct UpsertAssistantDefinitionParams<'a> { pub description_i18n: &'a str, pub avatar_type: &'a str, pub avatar_value: Option<&'a str>, - pub agent_backend: &'a str, + pub agent_id: &'a str, pub rule_resource_type: &'a str, pub rule_resource_ref: Option<&'a str>, pub rule_inline_content: Option<&'a str>, @@ -198,7 +198,7 @@ pub struct UpsertAssistantOverlayParams<'a> { pub definition_id: &'a str, pub enabled: bool, pub sort_order: i32, - pub agent_backend_override: Option<&'a str>, + pub agent_id_override: Option<&'a str>, pub last_used_at: Option, } diff --git a/crates/aionui-db/src/models/conversation.rs b/crates/aionui-db/src/models/conversation.rs index b73c35c19..80317e623 100644 --- a/crates/aionui-db/src/models/conversation.rs +++ b/crates/aionui-db/src/models/conversation.rs @@ -44,7 +44,7 @@ pub struct ConversationAssistantSnapshotRow { pub assistant_name: String, pub assistant_avatar_type: String, pub assistant_avatar_value: Option, - pub agent_backend: String, + pub agent_id: String, pub rules_content: String, pub default_model_mode: String, pub resolved_model_id: Option, @@ -69,7 +69,7 @@ pub struct UpsertConversationAssistantSnapshotParams<'a> { pub assistant_name: &'a str, pub assistant_avatar_type: &'a str, pub assistant_avatar_value: Option<&'a str>, - pub agent_backend: &'a str, + pub agent_id: &'a str, pub rules_content: &'a str, pub default_model_mode: &'a str, pub resolved_model_id: Option<&'a str>, diff --git a/crates/aionui-db/src/repository/sqlite_assistant.rs b/crates/aionui-db/src/repository/sqlite_assistant.rs index 17c9dcad4..63d2b9d0b 100644 --- a/crates/aionui-db/src/repository/sqlite_assistant.rs +++ b/crates/aionui-db/src/repository/sqlite_assistant.rs @@ -3,6 +3,7 @@ use aionui_common::{TimestampMs, now_ms}; use sqlx::SqlitePool; +use crate::agent_binding::resolve_agent_binding; use crate::error::DbError; use crate::models::{ AssistantDefinitionRow, AssistantOverlayRow, AssistantOverrideRow, AssistantPreferenceRow, AssistantRow, @@ -416,7 +417,7 @@ impl IAssistantDefinitionRepository for SqliteAssistantDefinitionRepository { "INSERT INTO assistant_definitions ( definition_id, assistant_key, source, owner_type, source_ref, source_version, source_hash, name, name_i18n, description, description_i18n, avatar_type, avatar_value, - agent_backend, rule_resource_type, rule_resource_ref, rule_inline_content, + agent_id, rule_resource_type, rule_resource_ref, rule_inline_content, recommended_prompts, recommended_prompts_i18n, default_model_mode, default_model_value, default_permission_mode, default_permission_value, @@ -437,7 +438,7 @@ impl IAssistantDefinitionRepository for SqliteAssistantDefinitionRepository { description_i18n = excluded.description_i18n, avatar_type = excluded.avatar_type, avatar_value = excluded.avatar_value, - agent_backend = excluded.agent_backend, + agent_id = excluded.agent_id, rule_resource_type = excluded.rule_resource_type, rule_resource_ref = excluded.rule_resource_ref, rule_inline_content = excluded.rule_inline_content, @@ -469,7 +470,7 @@ impl IAssistantDefinitionRepository for SqliteAssistantDefinitionRepository { .bind(params.description_i18n) .bind(params.avatar_type) .bind(params.avatar_value) - .bind(params.agent_backend) + .bind(params.agent_id) .bind(params.rule_resource_type) .bind(params.rule_resource_ref) .bind(params.rule_inline_content) @@ -536,19 +537,19 @@ impl IAssistantOverlayRepository for SqliteAssistantOverlayRepository { let now = now_ms(); sqlx::query( "INSERT INTO assistant_overlays ( - definition_id, enabled, sort_order, agent_backend_override, last_used_at, created_at, updated_at + definition_id, enabled, sort_order, agent_id_override, last_used_at, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(definition_id) DO UPDATE SET enabled = excluded.enabled, sort_order = excluded.sort_order, - agent_backend_override = excluded.agent_backend_override, + agent_id_override = excluded.agent_id_override, last_used_at = excluded.last_used_at, updated_at = excluded.updated_at", ) .bind(params.definition_id) .bind(params.enabled) .bind(params.sort_order) - .bind(params.agent_backend_override) + .bind(params.agent_id_override) .bind(params.last_used_at) .bind(now) .bind(now) @@ -642,6 +643,10 @@ pub async fn rebuild_legacy_assistant_mirror( ("fixed", Some(model)) => serde_json::to_string(&vec![model]).unwrap_or_else(|_| "[]".to_string()), _ => "[]".to_string(), }; + let legacy_preset_agent_type = resolve_agent_binding(pool, &definition.agent_id) + .await? + .map(|resolution| resolution.runtime_backend) + .unwrap_or_else(|| definition.agent_id.clone()); if definition.source == "user" { sqlx::query( @@ -669,7 +674,7 @@ pub async fn rebuild_legacy_assistant_mirror( .bind(&definition.name) .bind(&definition.description) .bind(&definition.avatar_value) - .bind(&definition.agent_backend) + .bind(&legacy_preset_agent_type) .bind(&default_skills) .bind(&custom_skill_names) .bind(&disabled_builtin) @@ -691,7 +696,15 @@ pub async fn rebuild_legacy_assistant_mirror( let enabled = state.map(|row| row.enabled).unwrap_or(true); let sort_order = state.map(|row| row.sort_order).unwrap_or_default(); - let agent_backend_override = state.and_then(|row| row.agent_backend_override.clone()); + let agent_id_override = match state.and_then(|row| row.agent_id_override.as_deref()) { + Some(agent_id) => Some( + resolve_agent_binding(pool, agent_id) + .await? + .map(|resolution| resolution.runtime_backend) + .unwrap_or_else(|| agent_id.to_owned()), + ), + None => None, + }; let last_used_at = state.and_then(|row| row.last_used_at); sqlx::query( @@ -707,7 +720,7 @@ pub async fn rebuild_legacy_assistant_mirror( .bind(&definition.assistant_key) .bind(enabled) .bind(sort_order) - .bind(agent_backend_override) + .bind(agent_id_override) .bind(last_used_at) .bind(state.map(|row| row.updated_at).unwrap_or(definition.updated_at)) .execute(pool) @@ -789,7 +802,7 @@ mod tests { description_i18n: "{}", avatar_type: "emoji", avatar_value: Some("🤖"), - agent_backend: "gemini", + agent_id: "gemini", rule_resource_type: "inline", rule_resource_ref: None, rule_inline_content: Some("# rule"), @@ -1092,7 +1105,7 @@ mod tests { definition_id: &definition.definition_id, enabled: false, sort_order: 9, - agent_backend_override: Some("claude"), + agent_id_override: Some("claude"), last_used_at: Some(1234), }) .await @@ -1103,7 +1116,7 @@ mod tests { assert_eq!(list[0].definition_id, definition.definition_id); assert!(!list[0].enabled); assert_eq!(list[0].sort_order, 9); - assert_eq!(list[0].agent_backend_override.as_deref(), Some("claude")); + assert_eq!(list[0].agent_id_override.as_deref(), Some("claude")); } #[tokio::test] @@ -1136,7 +1149,7 @@ mod tests { definition_id: &definition.definition_id, enabled: false, sort_order: 7, - agent_backend_override: Some("claude"), + agent_id_override: Some("claude"), last_used_at: Some(999), }) .await @@ -1164,6 +1177,42 @@ mod tests { assert_eq!(legacy_override.last_used_at, Some(999)); } + #[tokio::test] + async fn rebuild_legacy_mirror_writes_runtime_backend_from_agent_id() { + let (d, s, _p, db) = setup_v2().await; + let mut params = definition_params("u2", "User Two"); + params.definition_id = "asstdef_u2"; + params.agent_id = "cc126dd5"; + let definition = d.upsert(¶ms).await.unwrap(); + let state = s + .upsert(&UpsertAssistantOverlayParams { + definition_id: &definition.definition_id, + enabled: true, + sort_order: 0, + agent_id_override: Some("2d23ff1c"), + last_used_at: None, + }) + .await + .unwrap(); + + rebuild_legacy_assistant_mirror(db.pool(), &definition, Some(&state)) + .await + .unwrap(); + + let legacy_assistant = sqlx::query_as::<_, AssistantRow>("SELECT * FROM assistants WHERE id = 'u2'") + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(legacy_assistant.preset_agent_type, "gemini"); + + let legacy_override = + sqlx::query_as::<_, AssistantOverrideRow>("SELECT * FROM assistant_overrides WHERE assistant_id = 'u2'") + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(legacy_override.preset_agent_type.as_deref(), Some("claude")); + } + #[tokio::test] async fn rebuild_legacy_mirror_skips_builtin_assistant_rows() { let (d, s, _p, db) = setup_v2().await; @@ -1182,7 +1231,7 @@ mod tests { description_i18n: "{}", avatar_type: "builtin_asset", avatar_value: Some("office.svg"), - agent_backend: "aionrs", + agent_id: "aionrs", rule_resource_type: "builtin_asset", rule_resource_ref: Some("builtin-office"), rule_inline_content: None, @@ -1206,7 +1255,7 @@ mod tests { definition_id: &definition.definition_id, enabled: false, sort_order: 3, - agent_backend_override: Some("claude"), + agent_id_override: Some("claude"), last_used_at: Some(42), }) .await diff --git a/crates/aionui-db/src/repository/sqlite_conversation.rs b/crates/aionui-db/src/repository/sqlite_conversation.rs index 2d06c6d1c..9de132c0c 100644 --- a/crates/aionui-db/src/repository/sqlite_conversation.rs +++ b/crates/aionui-db/src/repository/sqlite_conversation.rs @@ -299,7 +299,7 @@ impl IConversationRepository for SqliteConversationRepository { assistant_name, assistant_avatar_type, assistant_avatar_value, - agent_backend, + agent_id, rules_content, default_model_mode, resolved_model_id, @@ -320,7 +320,7 @@ impl IConversationRepository for SqliteConversationRepository { assistant_name = excluded.assistant_name, assistant_avatar_type = excluded.assistant_avatar_type, assistant_avatar_value = excluded.assistant_avatar_value, - agent_backend = excluded.agent_backend, + agent_id = excluded.agent_id, rules_content = excluded.rules_content, default_model_mode = excluded.default_model_mode, resolved_model_id = excluded.resolved_model_id, @@ -340,7 +340,7 @@ impl IConversationRepository for SqliteConversationRepository { .bind(params.assistant_name) .bind(params.assistant_avatar_type) .bind(params.assistant_avatar_value) - .bind(params.agent_backend) + .bind(params.agent_id) .bind(params.rules_content) .bind(params.default_model_mode) .bind(params.resolved_model_id) diff --git a/crates/aionui-db/tests/agent_binding_resolver.rs b/crates/aionui-db/tests/agent_binding_resolver.rs new file mode 100644 index 000000000..81511343d --- /dev/null +++ b/crates/aionui-db/tests/agent_binding_resolver.rs @@ -0,0 +1,36 @@ +use aionui_db::{AgentBindingResolution, init_database_memory, resolve_agent_binding}; + +#[tokio::test] +async fn resolves_legacy_backend_to_agent_metadata_id() { + let db = init_database_memory().await.unwrap(); + + let resolved = resolve_agent_binding(db.pool(), "codex") + .await + .unwrap() + .expect("codex should resolve"); + + assert_eq!( + resolved, + AgentBindingResolution { + agent_id: "8e1acf31".to_owned(), + agent_source: "builtin".to_owned(), + agent_type: "acp".to_owned(), + runtime_backend: "codex".to_owned(), + } + ); +} + +#[tokio::test] +async fn resolves_internal_agent_type_when_backend_is_null() { + let db = init_database_memory().await.unwrap(); + + let resolved = resolve_agent_binding(db.pool(), "aionrs") + .await + .unwrap() + .expect("aionrs should resolve"); + + assert_eq!(resolved.agent_id, "632f31d2"); + assert_eq!(resolved.agent_source, "internal"); + assert_eq!(resolved.agent_type, "aionrs"); + assert_eq!(resolved.runtime_backend, "aionrs"); +} diff --git a/crates/aionui-db/tests/assistant_data_unification_schema.rs b/crates/aionui-db/tests/assistant_data_unification_schema.rs index 82e3df980..890b3f9a9 100644 --- a/crates/aionui-db/tests/assistant_data_unification_schema.rs +++ b/crates/aionui-db/tests/assistant_data_unification_schema.rs @@ -81,6 +81,34 @@ async fn assistant_definition_table_has_expected_default_columns() { assert!(snapshot_columns.iter().any(|name| name == "resolved_mcp_ids")); } +#[tokio::test] +async fn assistant_agent_identity_columns_are_named_for_agent_metadata_id() { + let db = init_database_memory().await.unwrap(); + + let definition_columns: Vec = + sqlx::query_scalar("SELECT name FROM pragma_table_info('assistant_definitions')") + .fetch_all(db.pool()) + .await + .unwrap(); + assert!(definition_columns.iter().any(|name| name == "agent_id")); + assert!(!definition_columns.iter().any(|name| name == "agent_backend")); + + let overlay_columns: Vec = sqlx::query_scalar("SELECT name FROM pragma_table_info('assistant_overlays')") + .fetch_all(db.pool()) + .await + .unwrap(); + assert!(overlay_columns.iter().any(|name| name == "agent_id_override")); + assert!(!overlay_columns.iter().any(|name| name == "agent_backend_override")); + + let snapshot_columns: Vec = + sqlx::query_scalar("SELECT name FROM pragma_table_info('conversation_assistant_snapshots')") + .fetch_all(db.pool()) + .await + .unwrap(); + assert!(snapshot_columns.iter().any(|name| name == "agent_id")); + assert!(!snapshot_columns.iter().any(|name| name == "agent_backend")); +} + #[tokio::test] async fn assistant_definition_table_rejects_extension_source_and_owner_type() { let db = init_database_memory().await.unwrap(); @@ -89,7 +117,7 @@ async fn assistant_definition_table_rejects_extension_source_and_owner_type() { r#" INSERT INTO assistant_definitions ( definition_id, assistant_key, source, owner_type, source_ref, - name, name_i18n, description_i18n, avatar_type, agent_backend, + name, name_i18n, description_i18n, avatar_type, agent_id, rule_resource_type, recommended_prompts, recommended_prompts_i18n, default_model_mode, default_permission_mode, default_skills_mode, default_skill_ids, custom_skill_names, default_disabled_builtin_skill_ids, default_mcps_mode, default_mcp_ids, @@ -113,7 +141,7 @@ async fn assistant_definition_table_rejects_extension_source_and_owner_type() { r#" INSERT INTO assistant_definitions ( definition_id, assistant_key, source, owner_type, source_ref, - name, name_i18n, description_i18n, avatar_type, agent_backend, + name, name_i18n, description_i18n, avatar_type, agent_id, rule_resource_type, recommended_prompts, recommended_prompts_i18n, default_model_mode, default_permission_mode, default_skills_mode, default_skill_ids, custom_skill_names, default_disabled_builtin_skill_ids, default_mcps_mode, default_mcp_ids, diff --git a/crates/aionui-team/src/guide/server.rs b/crates/aionui-team/src/guide/server.rs index d6f63adef..5ca01d31a 100644 --- a/crates/aionui-team/src/guide/server.rs +++ b/crates/aionui-team/src/guide/server.rs @@ -852,7 +852,7 @@ mod tests { description_i18n: "{}".into(), avatar_type: "emoji".into(), avatar_value: Some("🤖".into()), - agent_backend: "claude".into(), + agent_id: "claude".into(), rule_resource_type: "inline".into(), rule_resource_ref: None, rule_inline_content: None, @@ -878,7 +878,7 @@ mod tests { definition_id: "def-guide-models".into(), enabled: true, sort_order: 0, - agent_backend_override: None, + agent_id_override: None, last_used_at: None, created_at: 0, updated_at: 0, @@ -934,7 +934,7 @@ mod tests { description_i18n: "{}".into(), avatar_type: "emoji".into(), avatar_value: Some("🤖".into()), - agent_backend: "claude".into(), + agent_id: "claude".into(), rule_resource_type: "inline".into(), rule_resource_ref: None, rule_inline_content: None, @@ -960,7 +960,7 @@ mod tests { definition_id: "def-guide-lead".into(), enabled: true, sort_order: 0, - agent_backend_override: Some("codex".into()), + agent_id_override: Some("codex".into()), last_used_at: None, created_at: 0, updated_at: 0, @@ -1046,7 +1046,7 @@ mod tests { description_i18n: "{}".into(), avatar_type: "emoji".into(), avatar_value: Some("🤖".into()), - agent_backend: "claude".into(), + agent_id: "claude".into(), rule_resource_type: "inline".into(), rule_resource_ref: None, rule_inline_content: None, @@ -1072,7 +1072,7 @@ mod tests { definition_id: "def-guide-teamrun".into(), enabled: true, sort_order: 0, - agent_backend_override: Some("claude".into()), + agent_id_override: Some("claude".into()), last_used_at: None, created_at: 0, updated_at: 0, @@ -1148,7 +1148,7 @@ mod tests { description_i18n: "{}".into(), avatar_type: "emoji".into(), avatar_value: Some("🤖".into()), - agent_backend: "claude".into(), + agent_id: "claude".into(), rule_resource_type: "inline".into(), rule_resource_ref: None, rule_inline_content: None, @@ -1174,7 +1174,7 @@ mod tests { definition_id: "def-guide-summary".into(), enabled: true, sort_order: 0, - agent_backend_override: Some("claude".into()), + agent_id_override: Some("claude".into()), last_used_at: None, created_at: 0, updated_at: 0, @@ -1251,7 +1251,7 @@ mod tests { description_i18n: "{}".into(), avatar_type: "emoji".into(), avatar_value: Some("🤖".into()), - agent_backend: "claude".into(), + agent_id: "claude".into(), rule_resource_type: "inline".into(), rule_resource_ref: None, rule_inline_content: None, @@ -1277,7 +1277,7 @@ mod tests { definition_id: "def-guide-non-assistant-caller".into(), enabled: true, sort_order: 0, - agent_backend_override: Some("codex".into()), + agent_id_override: Some("codex".into()), last_used_at: None, created_at: 0, updated_at: 0, diff --git a/crates/aionui-team/src/provisioning.rs b/crates/aionui-team/src/provisioning.rs index 511611f39..097e49c7c 100644 --- a/crates/aionui-team/src/provisioning.rs +++ b/crates/aionui-team/src/provisioning.rs @@ -5,7 +5,8 @@ use aionui_api_types::{AddAgentRequest, TeamAgentInput}; use aionui_common::{AgentKillReason, AgentType, ProviderWithModel, generate_id}; use aionui_db::models::TeamRow; use aionui_db::{ - IAssistantDefinitionRepository, IAssistantOverlayRepository, IProviderRepository, ITeamRepository, UpdateTeamParams, + IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, IProviderRepository, + ITeamRepository, UpdateTeamParams, }; use async_trait::async_trait; use tracing::{info, warn}; @@ -13,13 +14,14 @@ use tracing::{info, warn}; use crate::error::TeamError; use crate::mcp::TeamMcpStdioConfig; use crate::service::inherit_team_workspace; -use crate::service::spawn_support::{parse_agent_type, resolve_full_auto_mode}; +use crate::service::spawn_support::{parse_agent_type, resolve_full_auto_mode, resolve_runtime_backend}; use crate::types::{Team, TeamAgent, TeammateRole}; use crate::workspace::TeamWorkspaceResolver; #[derive(Clone)] pub struct TeamAgentProvisioner { repo: Arc, + agent_metadata_repo: Arc, assistant_definition_repo: Arc, assistant_overlay_repo: Arc, provider_repo: Arc, @@ -118,6 +120,7 @@ impl TeamAgentProvisioner { pub(crate) fn new( repo: Arc, + agent_metadata_repo: Arc, assistant_definition_repo: Arc, assistant_overlay_repo: Arc, provider_repo: Arc, @@ -125,6 +128,7 @@ impl TeamAgentProvisioner { ) -> Self { Self { repo, + agent_metadata_repo, assistant_definition_repo, assistant_overlay_repo, provider_repo, @@ -291,10 +295,11 @@ impl TeamAgentProvisioner { .await? .ok_or_else(|| TeamError::InvalidRequest(format!("Preset assistant not found: {assistant_key}")))?; let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; - return Ok(overlay - .and_then(|row| row.agent_backend_override) + let effective_agent_id = overlay + .and_then(|row| row.agent_id_override) .filter(|value| !value.trim().is_empty()) - .unwrap_or(definition.agent_backend)); + .unwrap_or(definition.agent_id); + return resolve_runtime_backend(&self.agent_metadata_repo, &effective_agent_id).await; } let Some(requested_backend) = requested_backend.map(str::trim).filter(|value| !value.is_empty()) else { diff --git a/crates/aionui-team/src/service.rs b/crates/aionui-team/src/service.rs index 56094dbeb..878715e2b 100644 --- a/crates/aionui-team/src/service.rs +++ b/crates/aionui-team/src/service.rs @@ -122,6 +122,7 @@ impl TeamSessionService { pub(crate) fn provisioner(&self) -> TeamAgentProvisioner { TeamAgentProvisioner::new( self.repo.clone(), + self.agent_metadata_repo.clone(), self.assistant_definition_repo.clone(), self.assistant_overlay_repo.clone(), self.provider_repo.clone(), diff --git a/crates/aionui-team/src/service/describe_support.rs b/crates/aionui-team/src/service/describe_support.rs index 00201a5ea..f5bb5fff9 100644 --- a/crates/aionui-team/src/service/describe_support.rs +++ b/crates/aionui-team/src/service/describe_support.rs @@ -3,7 +3,8 @@ use std::fmt::Write; use crate::error::TeamError; use crate::service::TeamSessionService; -use aionui_db::models::{AssistantDefinitionRow, AssistantOverlayRow}; +use crate::service::spawn_support::resolve_runtime_backend; +use aionui_db::models::AssistantDefinitionRow; impl TeamSessionService { pub(crate) async fn describe_assistant( @@ -17,24 +18,22 @@ impl TeamSessionService { .await? .ok_or_else(|| TeamError::InvalidRequest(format!("Preset assistant not found: {assistant_key}")))?; let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; + let effective_agent_id = overlay + .as_ref() + .and_then(|row| row.agent_id_override.as_deref()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(definition.agent_id.as_str()); + let effective_backend = resolve_runtime_backend(&self.agent_metadata_repo, effective_agent_id).await?; Ok(render_assistant_description( &definition, - overlay.as_ref(), + &effective_backend, locale.unwrap_or("en-US"), )) } } -fn render_assistant_description( - definition: &AssistantDefinitionRow, - overlay: Option<&AssistantOverlayRow>, - locale: &str, -) -> String { - let effective_backend = overlay - .and_then(|row| row.agent_backend_override.as_deref()) - .filter(|value| !value.trim().is_empty()) - .unwrap_or(definition.agent_backend.as_str()); +fn render_assistant_description(definition: &AssistantDefinitionRow, effective_backend: &str, locale: &str) -> String { let name_map = decode_str_map(&definition.name_i18n); let description_map = decode_str_map(&definition.description_i18n); let prompts_map = decode_list_map(&definition.recommended_prompts_i18n); diff --git a/crates/aionui-team/src/service/spawn_support.rs b/crates/aionui-team/src/service/spawn_support.rs index ef709d195..1754be6b2 100644 --- a/crates/aionui-team/src/service/spawn_support.rs +++ b/crates/aionui-team/src/service/spawn_support.rs @@ -3,7 +3,9 @@ use aionui_api_types::BehaviorPolicy; use aionui_common::AgentType; use aionui_common::constants::{TEAM_CAPABLE_BACKENDS, has_mcp_capability}; use aionui_db::models::AssistantOverlayRow; +use aionui_db::{IAgentMetadataRepository, resolve_agent_binding_from_rows}; use std::collections::HashMap; +use std::sync::Arc; use crate::prompts::AvailableAssistant; @@ -64,6 +66,16 @@ pub(crate) fn resolve_full_auto_mode(backend: &str) -> &'static str { agent_type.full_auto_mode_id(Some(backend)) } +pub(crate) async fn resolve_runtime_backend( + agent_metadata_repo: &Arc, + agent_id: &str, +) -> Result { + let rows = agent_metadata_repo.list_all().await?; + Ok(resolve_agent_binding_from_rows(&rows, agent_id) + .map(|binding| binding.runtime_backend) + .unwrap_or_else(|| agent_id.to_owned())) +} + impl TeamSessionService { pub(crate) async fn resolve_spawn_backend_and_model( &self, @@ -79,12 +91,12 @@ impl TeamSessionService { .await? .ok_or_else(|| TeamError::InvalidRequest(format!("Preset assistant not found: {assistant_key}")))?; let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; - let backend = overlay + let effective_agent_id = overlay .as_ref() - .and_then(|row| row.agent_backend_override.as_deref()) + .and_then(|row| row.agent_id_override.as_deref()) .filter(|value| !value.trim().is_empty()) - .unwrap_or(definition.agent_backend.as_str()) - .to_owned(); + .unwrap_or(definition.agent_id.as_str()); + let backend = resolve_runtime_backend(&self.agent_metadata_repo, effective_agent_id).await?; let requested_model = requested_model .map(str::trim) .filter(|value| !value.is_empty()) @@ -170,10 +182,13 @@ impl TeamSessionService { continue; } - let effective_backend = overlay - .and_then(|row| row.agent_backend_override.as_deref()) + let effective_agent_id = overlay + .and_then(|row| row.agent_id_override.as_deref()) .filter(|value| !value.trim().is_empty()) - .unwrap_or(definition.agent_backend.as_str()); + .unwrap_or(definition.agent_id.as_str()); + let effective_backend = resolve_runtime_backend(&self.agent_metadata_repo, effective_agent_id) + .await + .unwrap_or_else(|_| effective_agent_id.to_owned()); let agent_row = if source == "generated" { definition @@ -183,16 +198,18 @@ impl TeamSessionService { } else { agent_rows .iter() - .find(|row| row.backend.as_deref() == Some(effective_backend) && row.agent_source != "custom") + .find(|row| { + row.backend.as_deref() == Some(effective_backend.as_str()) && row.agent_source != "custom" + }) .or_else(|| { agent_rows .iter() - .find(|row| row.backend.as_deref() == Some(effective_backend)) + .find(|row| row.backend.as_deref() == Some(effective_backend.as_str())) }) }; let is_available = agent_row.is_some_and(|row| row.last_check_status.as_deref() != Some("unavailable")); - let is_team_capable = self.is_backend_team_capable(effective_backend).await; + let is_team_capable = self.is_backend_team_capable(&effective_backend).await; if !(is_available && is_team_capable) { continue; } @@ -242,12 +259,15 @@ impl TeamSessionService { .ok_or_else(|| TeamError::InvalidRequest(format!("Assistant not found: {assistant_key}")))?; let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; Some( - overlay - .as_ref() - .and_then(|row| row.agent_backend_override.as_deref()) - .filter(|value| !value.trim().is_empty()) - .unwrap_or(definition.agent_backend.as_str()) - .to_owned(), + resolve_runtime_backend( + &self.agent_metadata_repo, + overlay + .as_ref() + .and_then(|row| row.agent_id_override.as_deref()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(definition.agent_id.as_str()), + ) + .await?, ) } None => None, @@ -551,7 +571,7 @@ mod tests { description_i18n: "{}".into(), avatar_type: "emoji".into(), avatar_value: None, - agent_backend: "aionrs".into(), + agent_id: "aionrs".into(), rule_resource_type: "inline".into(), rule_resource_ref: None, rule_inline_content: None, @@ -577,7 +597,7 @@ mod tests { definition_id: "def-1".into(), enabled: true, sort_order: 0, - agent_backend_override: None, + agent_id_override: None, last_used_at: None, created_at: 0, updated_at: 0, diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index 3bfc66eaa..58307233c 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -1748,7 +1748,7 @@ fn word_creator_definition() -> AssistantDefinitionRow { description_i18n: "{}".into(), avatar_type: "builtin_asset".into(), avatar_value: None, - agent_backend: "claude".into(), + agent_id: "claude".into(), rule_resource_type: "inline".into(), rule_resource_ref: None, rule_inline_content: None, @@ -1931,7 +1931,7 @@ async fn tc_create_team_prefers_assistant_avatar_over_backend_logo() { description_i18n: "{}".into(), avatar_type: "builtin_asset".into(), avatar_value: Some("avatars/assistant-lead.png".into()), - agent_backend: "claude".into(), + agent_id: "claude".into(), rule_resource_type: "inline".into(), rule_resource_ref: None, rule_inline_content: None, @@ -2002,7 +2002,7 @@ async fn tc_create_team_carries_assistant_identity_into_lead_conversation_extra( description_i18n: "{}".into(), avatar_type: "emoji".into(), avatar_value: Some("🤖".into()), - agent_backend: "claude".into(), + agent_id: "claude".into(), rule_resource_type: "inline".into(), rule_resource_ref: None, rule_inline_content: None, @@ -2078,7 +2078,7 @@ async fn tc_create_team_derives_backend_from_assistant_when_backend_missing() { description_i18n: "{}".into(), avatar_type: "emoji".into(), avatar_value: Some("🤖".into()), - agent_backend: "claude".into(), + agent_id: "claude".into(), rule_resource_type: "inline".into(), rule_resource_ref: None, rule_inline_content: None, @@ -2104,7 +2104,7 @@ async fn tc_create_team_derives_backend_from_assistant_when_backend_missing() { definition_id: "def-team-lead".into(), enabled: true, sort_order: 0, - agent_backend_override: Some("codex".into()), + agent_id_override: Some("codex".into()), last_used_at: None, created_at: 0, updated_at: 0, @@ -2165,7 +2165,7 @@ async fn tc_create_team_ignores_requested_backend_when_assistant_id_present() { description_i18n: "{}".into(), avatar_type: "emoji".into(), avatar_value: Some("🤖".into()), - agent_backend: "claude".into(), + agent_id: "claude".into(), rule_resource_type: "inline".into(), rule_resource_ref: None, rule_inline_content: None, @@ -2191,7 +2191,7 @@ async fn tc_create_team_ignores_requested_backend_when_assistant_id_present() { definition_id: "def-team-lead".into(), enabled: true, sort_order: 0, - agent_backend_override: Some("codex".into()), + agent_id_override: Some("codex".into()), last_used_at: None, created_at: 0, updated_at: 0, @@ -2413,7 +2413,7 @@ async fn ta_add_agent_derives_backend_from_assistant_when_backend_missing() { description_i18n: "{}".into(), avatar_type: "emoji".into(), avatar_value: Some("🤖".into()), - agent_backend: "claude".into(), + agent_id: "claude".into(), rule_resource_type: "inline".into(), rule_resource_ref: None, rule_inline_content: None, @@ -2439,7 +2439,7 @@ async fn ta_add_agent_derives_backend_from_assistant_when_backend_missing() { definition_id: "def-team-worker".into(), enabled: true, sort_order: 0, - agent_backend_override: Some("codex".into()), + agent_id_override: Some("codex".into()), last_used_at: None, created_at: 0, updated_at: 0, @@ -2508,7 +2508,7 @@ async fn ta_add_agent_ignores_requested_backend_when_assistant_id_present() { description_i18n: "{}".into(), avatar_type: "emoji".into(), avatar_value: Some("🤖".into()), - agent_backend: "claude".into(), + agent_id: "claude".into(), rule_resource_type: "inline".into(), rule_resource_ref: None, rule_inline_content: None, @@ -2534,7 +2534,7 @@ async fn ta_add_agent_ignores_requested_backend_when_assistant_id_present() { definition_id: "def-team-worker".into(), enabled: true, sort_order: 0, - agent_backend_override: Some("codex".into()), + agent_id_override: Some("codex".into()), last_used_at: None, created_at: 0, updated_at: 0, @@ -3456,7 +3456,7 @@ async fn spawn_agent_in_session_succeeds_without_active_team_run() { description_i18n: "{}".into(), avatar_type: "emoji".into(), avatar_value: Some("🤖".into()), - agent_backend: "claude".into(), + agent_id: "claude".into(), rule_resource_type: "inline".into(), rule_resource_ref: None, rule_inline_content: None, @@ -3482,7 +3482,7 @@ async fn spawn_agent_in_session_succeeds_without_active_team_run() { definition_id: "def-spawn-worker".into(), enabled: true, sort_order: 0, - agent_backend_override: Some("codex".into()), + agent_id_override: Some("codex".into()), last_used_at: None, created_at: 0, updated_at: 0, From c74e32b688c39135b98774a154f50bf7b8e21cdd Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 23 Jun 2026 11:41:15 +0800 Subject: [PATCH 123/135] perf(agent): remove background availability probes --- .../src/protocol/custom_agent_probe.rs | 11 +- crates/aionui-ai-agent/src/services/agent.rs | 4 - .../src/services/availability/mod.rs | 142 +++++++----------- crates/aionui-app/src/router/state.rs | 1 - .../aionui-conversation/src/service_test.rs | 42 ++++++ .../src/turn_orchestrator.rs | 20 +++ 6 files changed, 119 insertions(+), 101 deletions(-) diff --git a/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs b/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs index 726508b8e..941814f6c 100644 --- a/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs +++ b/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs @@ -34,9 +34,8 @@ use agent_client_protocol::schema::NewSessionRequest; const STEP2_TIMEOUT: Duration = Duration::from_secs(35); /// Grace period for the child to exit on its own after stdin close, before -/// we fall back to SIGKILL on the whole process group. Short because the -/// availability scheduler runs this every 5 minutes for every agent and any -/// cleanup latency stacks up. +/// we fall back to SIGKILL on the whole process group. Keep this short because +/// manual connection tests should return promptly after the ACP probe finishes. const PROBE_KILL_GRACE: Duration = Duration::from_millis(500); /// Probe a custom ACP agent. @@ -365,9 +364,9 @@ mod tests { /// Regression for the production leak: a probe that talks to a wrapper /// CLI (`npm exec ...`, etc.) historically left the wrapper's grandchild /// process alive when the probe returned, because cleanup relied on - /// `kill_on_drop(true)` which only signals the direct child. Over many - /// availability scheduler iterations this accumulated dozens of zombie - /// `openclaw-acp` processes per day. + /// `kill_on_drop(true)` which only signals the direct child. Repeated + /// connection tests could otherwise accumulate zombie `openclaw-acp` + /// processes. /// /// We exercise the public entry point with a CLI that exits immediately /// after backgrounding a long-lived grandchild — that's the production diff --git a/crates/aionui-ai-agent/src/services/agent.rs b/crates/aionui-ai-agent/src/services/agent.rs index c5dbf7a58..08f58cf5a 100644 --- a/crates/aionui-ai-agent/src/services/agent.rs +++ b/crates/aionui-ai-agent/src/services/agent.rs @@ -69,10 +69,6 @@ impl AgentService { &self.broadcaster } - pub fn start_background_scheduler(&self) { - self.availability.start_background_scheduler(); - } - pub fn availability_feedback_port(&self) -> Arc { Arc::new(self.availability.clone()) } diff --git a/crates/aionui-ai-agent/src/services/availability/mod.rs b/crates/aionui-ai-agent/src/services/availability/mod.rs index cf9bebb27..09d7a275e 100644 --- a/crates/aionui-ai-agent/src/services/availability/mod.rs +++ b/crates/aionui-ai-agent/src/services/availability/mod.rs @@ -1,9 +1,6 @@ use std::collections::HashMap; use std::path::PathBuf; -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, -}; +use std::sync::Arc; use std::time::Instant; use aionui_api_types::{ @@ -16,17 +13,15 @@ use aionui_db::{IProviderRepository, UpdateAgentAvailabilitySnapshotParams}; use aionui_runtime::{ ManagedAcpToolId, ensure_managed_acp_tool_with_reporter, ensure_node_runtime_with_reporter, resolve_command_path, }; -use tokio::time::{Duration, sleep}; +use tokio::time::Duration; use crate::error::AgentError; use crate::protocol::{cli_detect, custom_agent_probe}; use crate::registry::{AgentRegistry, guidance_for_snapshot_error_code}; -const DEFAULT_STARTUP_DELAY: Duration = Duration::from_secs(15); -const DEFAULT_SCHEDULED_INTERVAL: Duration = Duration::from_secs(300); - #[async_trait::async_trait] pub trait AgentAvailabilityFeedbackPort: Send + Sync { + async fn record_session_success(&self, agent_id: &str) -> Result<(), AgentError>; async fn record_session_failure(&self, agent_id: &str, code: &str, message: &str) -> Result<(), AgentError>; } @@ -46,9 +41,6 @@ pub struct AgentAvailabilityService { // Used to decide aionrs (built-in, no external CLI) availability: it is // usable only when at least one model provider is configured & enabled. provider_repo: Arc, - scheduler_started: Arc, - startup_delay: Duration, - scheduled_interval: Duration, } impl AgentAvailabilityService { @@ -57,33 +49,9 @@ impl AgentAvailabilityService { registry, data_dir, provider_repo, - scheduler_started: Arc::new(AtomicBool::new(false)), - startup_delay: DEFAULT_STARTUP_DELAY, - scheduled_interval: DEFAULT_SCHEDULED_INTERVAL, } } - pub fn start_background_scheduler(&self) { - if self - .scheduler_started - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_err() - { - return; - } - - let service = self.clone(); - tokio::spawn(async move { - sleep(service.startup_delay).await; - loop { - if let Err(error) = service.run_scheduled_probe_pass().await { - tracing::warn!(error = %error, "agent availability scheduled probe pass failed"); - } - sleep(service.scheduled_interval).await; - } - }); - } - pub async fn list_management_rows(&self) -> Vec { self.registry.refresh_availability().await; self.registry.list_management_rows().await @@ -131,6 +99,19 @@ impl AgentAvailabilityService { self.persist_snapshot(agent_id, &snapshot).await } + pub async fn record_session_success(&self, agent_id: &str) -> Result<(), AgentError> { + let checked_at = now_ms(); + let snapshot = AvailabilitySnapshot { + status: "online", + kind: "session", + error_code: None, + error_message: None, + latency_ms: 0, + checked_at, + }; + self.persist_snapshot(agent_id, &snapshot).await + } + pub async fn management_row_by_id(&self, id: &str) -> Option { self.registry .list_management_rows() @@ -139,26 +120,6 @@ impl AgentAvailabilityService { .find(|row| row.id == id) } - async fn run_scheduled_probe_pass(&self) -> Result<(), AgentError> { - self.registry.invalidate_and_rehydrate().await?; - let rows = self.registry.list_all_including_hidden().await; - for meta in rows - .into_iter() - .filter(|item| item.enabled && item.available && item.agent_type.supports_new_conversation()) - { - let snapshot = run_probe( - &self.registry, - &self.provider_repo, - &meta, - &self.data_dir, - AgentSnapshotCheckKind::Scheduled, - ) - .await; - self.persist_snapshot(&meta.id, &snapshot).await?; - } - Ok(()) - } - async fn persist_snapshot(&self, id: &str, snapshot: &AvailabilitySnapshot) -> Result<(), AgentError> { let existing = self .registry @@ -398,6 +359,10 @@ async fn try_connect_builtin_managed_agent( #[async_trait::async_trait] impl AgentAvailabilityFeedbackPort for AgentAvailabilityService { + async fn record_session_success(&self, agent_id: &str) -> Result<(), AgentError> { + AgentAvailabilityService::record_session_success(self, agent_id).await + } + async fn record_session_failure(&self, agent_id: &str, code: &str, message: &str) -> Result<(), AgentError> { AgentAvailabilityService::record_session_failure(self, agent_id, code, message).await } @@ -405,8 +370,10 @@ impl AgentAvailabilityFeedbackPort for AgentAvailabilityService { #[cfg(test)] mod tests { - use std::sync::{Arc, atomic::AtomicBool}; + use std::sync::Arc; + use super::{AgentAvailabilityService, probe_aionrs_provider_readiness, run_probe}; + use crate::registry::AgentRegistry; use aionui_api_types::{ AgentHandshake, AgentManagementStatus, AgentMetadata, AgentSnapshotCheckKind, AgentSnapshotCheckStatus, AgentSource, AgentSourceInfo, BehaviorPolicy, @@ -416,10 +383,6 @@ mod tests { CreateProviderParams, IAgentMetadataRepository, IProviderRepository, SqliteAgentMetadataRepository, SqliteProviderRepository, UpsertAgentMetadataParams, init_database_memory, }; - use tokio::time::Duration; - - use super::{AgentAvailabilityService, probe_aionrs_provider_readiness, run_probe}; - use crate::registry::AgentRegistry; fn enabled_provider_params() -> CreateProviderParams<'static> { CreateProviderParams { @@ -534,15 +497,16 @@ mod tests { ); assert!(row.last_failure_at.is_some()); } + #[tokio::test] - async fn background_scheduler_persists_scheduled_snapshot() { + async fn record_session_success_persists_online_snapshot() { let db = init_database_memory().await.unwrap(); let repo: Arc = Arc::new(SqliteAgentMetadataRepository::new(db.pool().clone())); repo.upsert(&UpsertAgentMetadataParams { - id: "agent-scheduled-check", + id: "agent-session-success", icon: None, - name: "Scheduled Check Agent", + name: "Session Success Agent", name_i18n: None, description: None, description_i18n: None, @@ -572,37 +536,35 @@ mod tests { registry.hydrate().await.unwrap(); let provider_repo: Arc = Arc::new(SqliteProviderRepository::new(db.pool().clone())); - let service = AgentAvailabilityService { - registry: registry.clone(), - data_dir: std::env::temp_dir(), - provider_repo, - scheduler_started: Arc::new(AtomicBool::new(false)), - startup_delay: Duration::from_millis(10), - scheduled_interval: Duration::from_secs(60), - }; - service.start_background_scheduler(); + let service = AgentAvailabilityService::new(registry.clone(), provider_repo, std::env::temp_dir()); + service + .record_session_failure( + "agent-session-success", + "session_send_failed", + "provider returned 401 invalid api key", + ) + .await + .unwrap(); - let mut row = None; - for _ in 0..20 { - let candidate = service - .list_management_rows() - .await - .into_iter() - .find(|item| item.id == "agent-scheduled-check") - .unwrap(); - if candidate.last_check_kind == Some(AgentSnapshotCheckKind::Scheduled) { - row = Some(candidate); - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } + service.record_session_success("agent-session-success").await.unwrap(); - let row = row.expect("scheduled probe should persist a snapshot"); + let row = service + .list_management_rows() + .await + .into_iter() + .find(|item| item.id == "agent-session-success") + .unwrap(); - assert_eq!(row.last_check_kind, Some(AgentSnapshotCheckKind::Scheduled)); - assert!(row.last_check_status.is_some()); - assert!(row.last_check_at.is_some()); + assert_eq!(row.status, AgentManagementStatus::Online); + assert_eq!(row.last_check_status, Some(AgentSnapshotCheckStatus::Online)); + assert_eq!(row.last_check_kind, Some(AgentSnapshotCheckKind::Session)); + assert!(row.last_check_error_code.is_none()); + assert!(row.last_check_error_message.is_none()); + assert!(row.last_check_guidance.is_none()); + assert!(row.last_success_at.is_some()); + assert!(row.last_failure_at.is_some()); } + #[tokio::test] async fn managed_builtin_probe_checks_primary_binary_before_running_bridge_command() { let db = init_database_memory().await.unwrap(); diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index 8a0106adc..ebf8b21bd 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -236,7 +236,6 @@ pub async fn build_module_states( encryption_key, services.data_dir.clone(), ); - agent_service.start_background_scheduler(); services .conversation_service .with_agent_availability_feedback(agent_service.availability_feedback_port()); diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 1bb7cdbb3..590ed0f7a 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -174,11 +174,17 @@ struct RecordedAvailabilityFailure { #[derive(Default)] struct RecordingAvailabilityFeedback { + successes: Mutex>, failures: Mutex>, } #[async_trait::async_trait] impl AgentAvailabilityFeedbackPort for RecordingAvailabilityFeedback { + async fn record_session_success(&self, agent_id: &str) -> Result<(), AgentError> { + self.successes.lock().unwrap().push(agent_id.to_owned()); + Ok(()) + } + async fn record_session_failure(&self, agent_id: &str, code: &str, message: &str) -> Result<(), AgentError> { self.failures.lock().unwrap().push(RecordedAvailabilityFailure { agent_id: agent_id.to_owned(), @@ -3958,6 +3964,42 @@ async fn send_message_records_agent_availability_feedback_on_send_failure() { ); } +#[tokio::test] +async fn send_message_records_agent_availability_feedback_on_send_success() { + let (svc, _broadcaster, _repo, _default_task_mgr) = make_service(); + let task_mgr = Arc::new(MockTaskManager::new()); + let feedback = Arc::new(RecordingAvailabilityFeedback::default()); + svc.with_agent_availability_feedback(feedback.clone()); + + let mut create_req = make_create_req(); + create_req.name = Some("Feedback Success Conversation".into()); + create_req.source = Some(ConversationSource::Aionui); + create_req.extra = json!({ + "backend": "claude", + "agent_id": "agent-feedback-success", + "agent_source": "custom", + "workspace": ensure_test_workspace_path() + }); + + let conv = svc.create("user_1", create_req).await.unwrap(); + + let scripted_agent = Arc::new(ScriptedAgent::new( + &conv.id, + vec![vec![AgentStreamEvent::Finish(FinishEventData::default())]], + )); + task_mgr.insert_agent(&conv.id, AgentInstance::Mock(scripted_agent)); + + let task_mgr_dyn: Arc = task_mgr.clone(); + svc.send_message("user_1", &conv.id, make_send_req(), &task_mgr_dyn) + .await + .unwrap(); + wait_for_turn_released(&svc, &conv.id).await; + + let successes = feedback.successes.lock().unwrap().clone(); + assert_eq!(successes, vec!["agent-feedback-success".to_owned()]); + assert!(feedback.failures.lock().unwrap().is_empty()); +} + // ── stop_stream tests ─────────────────────────────────────────── #[tokio::test] diff --git a/crates/aionui-conversation/src/turn_orchestrator.rs b/crates/aionui-conversation/src/turn_orchestrator.rs index c12718b6b..4bc15b52f 100644 --- a/crates/aionui-conversation/src/turn_orchestrator.rs +++ b/crates/aionui-conversation/src/turn_orchestrator.rs @@ -274,6 +274,10 @@ impl ConversationTurnOrchestrator { } } + if !turn_failed { + record_agent_session_success(&self.service, availability_agent_id.as_deref()).await; + } + let was_deleting = turn_claim.release_for_turn(&turn_id); self.service .complete_released_turn(&conv_id, &turn_id, was_deleting) @@ -329,3 +333,19 @@ async fn record_agent_session_failure( ); } } + +async fn record_agent_session_success(service: &ConversationService, agent_id: Option<&str>) { + let Some(agent_id) = agent_id else { + return; + }; + let Some(feedback) = service.agent_availability_feedback() else { + return; + }; + if let Err(error) = feedback.record_session_success(agent_id).await { + warn!( + agent_id, + error = %ErrorChain(&error), + "Failed to record agent availability session success" + ); + } +} From 237eb924562da509b394d57501c6d7a30124ae3a Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 23 Jun 2026 17:55:45 +0800 Subject: [PATCH 124/135] refactor(assistant): normalize assistant and cron identity --- crates/aionui-api-types/src/cron.rs | 49 ++- .../src/router/team_conversation_adapters.rs | 2 +- crates/aionui-app/tests/assistants_e2e.rs | 18 +- crates/aionui-app/tests/conversation_e2e.rs | 14 +- crates/aionui-app/tests/cron_e2e.rs | 3 +- crates/aionui-assistant/src/service.rs | 183 +++++------ crates/aionui-channel/src/channel_settings.rs | 40 ++- .../tests/message_service_integration.rs | 14 +- crates/aionui-common/src/types.rs | 2 +- crates/aionui-conversation/src/convert.rs | 4 +- crates/aionui-conversation/src/service.rs | 20 +- .../aionui-conversation/src/service_test.rs | 74 ++--- crates/aionui-cron/src/executor.rs | 69 ++-- crates/aionui-cron/src/service.rs | 284 +++++++++++----- crates/aionui-cron/src/types.rs | 35 +- .../aionui-cron/tests/service_integration.rs | 79 +++-- .../013_agent_connection_snapshot.sql | 199 +++++++++++ crates/aionui-db/src/models/assistant.rs | 16 +- crates/aionui-db/src/models/conversation.rs | 4 +- crates/aionui-db/src/models/cron_job.rs | 3 - crates/aionui-db/src/repository/assistant.rs | 14 +- crates/aionui-db/src/repository/cron.rs | 1 - .../src/repository/sqlite_assistant.rs | 131 ++++---- .../src/repository/sqlite_conversation.rs | 6 +- .../aionui-db/src/repository/sqlite_cron.rs | 10 +- .../assistant_data_unification_schema.rs | 15 +- .../tests/cron_assistant_first_migration.rs | 311 ++++++++++++++++++ crates/aionui-db/tests/cron_repository.rs | 2 - crates/aionui-team/src/guide/server.rs | 46 +-- crates/aionui-team/src/mcp/server.rs | 4 +- crates/aionui-team/src/provisioning.rs | 10 +- .../src/service/describe_support.rs | 12 +- .../src/service/response_builder.rs | 8 +- .../aionui-team/src/service/spawn_support.rs | 42 +-- crates/aionui-team/src/test_utils.rs | 4 +- .../tests/session_service_integration.rs | 56 ++-- 36 files changed, 1203 insertions(+), 581 deletions(-) create mode 100644 crates/aionui-db/tests/cron_assistant_first_migration.rs diff --git a/crates/aionui-api-types/src/cron.rs b/crates/aionui-api-types/src/cron.rs index 5053f98f4..2efaa3e91 100644 --- a/crates/aionui-api-types/src/cron.rs +++ b/crates/aionui-api-types/src/cron.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use aionui_common::TimestampMs; +use aionui_common::{ProviderWithModel, TimestampMs}; use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- @@ -38,8 +38,6 @@ pub enum CronScheduleDto { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct CronAgentConfigReadDto { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub backend: Option, pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub cli_path: Option, @@ -56,6 +54,8 @@ pub struct CronAgentConfigReadDto { #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub config_options: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace: Option, @@ -64,8 +64,6 @@ pub struct CronAgentConfigReadDto { #[derive(Debug, Clone, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct CronAgentConfigWriteDto { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub backend: Option, pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub cli_path: Option, @@ -76,6 +74,8 @@ pub struct CronAgentConfigWriteDto { #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub config_options: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace: Option, @@ -234,7 +234,6 @@ mod write_tests { #[test] fn cron_agent_config_write_rejects_legacy_custom_agent_id() { let err = serde_json::from_value::(serde_json::json!({ - "backend": "claude", "name": "Helper", "assistant_id": "assistant-1", "custom_agent_id": "legacy-agent", @@ -247,7 +246,6 @@ mod write_tests { #[test] fn cron_agent_config_write_rejects_legacy_preset_flags() { let err = serde_json::from_value::(serde_json::json!({ - "backend": "claude", "name": "Helper", "assistant_id": "assistant-1", "is_preset": true, @@ -260,7 +258,19 @@ mod write_tests { } #[test] - fn cron_agent_config_write_allows_missing_backend_when_assistant_id_present() { + fn cron_agent_config_write_rejects_legacy_backend() { + let err = serde_json::from_value::(serde_json::json!({ + "backend": "claude", + "name": "Helper", + "assistant_id": "assistant-1", + })) + .expect_err("legacy backend must be rejected"); + + assert!(err.to_string().contains("backend")); + } + + #[test] + fn cron_agent_config_write_allows_assistant_only_payload() { let parsed = serde_json::from_value::(serde_json::json!({ "name": "Helper", "assistant_id": "assistant-1", @@ -268,7 +278,6 @@ mod write_tests { .expect("assistant-backed writes should not require backend"); assert_eq!(parsed.assistant_id.as_deref(), Some("assistant-1")); - assert!(parsed.backend.is_none()); } } @@ -438,7 +447,6 @@ mod tests { #[test] fn agent_config_full() { let raw = json!({ - "backend": "acp", "name": "Claude Agent", "cli_path": "/usr/bin/claude", "is_preset": true, @@ -447,25 +455,28 @@ mod tests { "preset_agent_type": "claude", "mode": "auto", "model_id": "claude-sonnet-4-6", + "model": {"provider_id": "provider-1", "model": "claude-sonnet-4-6"}, "config_options": {"key": "value"}, "workspace": "/tmp/ws" }); let c: CronAgentConfigReadDto = serde_json::from_value(raw).unwrap(); - assert_eq!(c.backend.as_deref(), Some("acp")); assert_eq!(c.name, "Claude Agent"); assert_eq!(c.cli_path.as_deref(), Some("/usr/bin/claude")); assert_eq!(c.is_preset, Some(true)); assert_eq!(c.assistant_id.as_deref(), Some("assistant-1")); assert_eq!(c.custom_agent_id.as_deref(), Some("agent-1")); assert_eq!(c.model_id.as_deref(), Some("claude-sonnet-4-6")); + assert_eq!( + c.model.as_ref().map(|model| model.provider_id.as_str()), + Some("provider-1") + ); assert_eq!(c.config_options.as_ref().unwrap()["key"], "value"); } #[test] fn agent_config_minimal() { - let raw = json!({"backend": "openai", "name": "GPT"}); + let raw = json!({"name": "GPT"}); let c: CronAgentConfigReadDto = serde_json::from_value(raw).unwrap(); - assert_eq!(c.backend.as_deref(), Some("openai")); assert_eq!(c.name, "GPT"); assert!(c.cli_path.is_none()); assert!(c.is_preset.is_none()); @@ -475,7 +486,6 @@ mod tests { #[test] fn agent_config_serialize_omits_none() { let c = CronAgentConfigReadDto { - backend: Some("acp".into()), name: "Test".into(), cli_path: None, is_preset: None, @@ -484,6 +494,7 @@ mod tests { preset_agent_type: None, mode: None, model_id: None, + model: None, config_options: None, workspace: None, }; @@ -496,7 +507,6 @@ mod tests { #[test] fn agent_config_roundtrip() { let c = CronAgentConfigReadDto { - backend: Some("acp".into()), name: "Agent".into(), cli_path: Some("/bin/x".into()), is_preset: Some(false), @@ -505,6 +515,11 @@ mod tests { preset_agent_type: None, mode: Some("plan".into()), model_id: Some("m1".into()), + model: Some(ProviderWithModel { + provider_id: "p1".into(), + model: "m1".into(), + use_model: None, + }), config_options: Some(HashMap::from([("a".into(), "b".into())])), workspace: Some("/ws".into()), }; @@ -540,7 +555,6 @@ mod tests { created_at: 1700000000000, updated_at: 1700001000000, agent_config: Some(CronAgentConfigReadDto { - backend: Some("acp".into()), name: "Claude".into(), cli_path: None, is_preset: None, @@ -549,6 +563,7 @@ mod tests { preset_agent_type: None, mode: None, model_id: None, + model: None, config_options: None, workspace: None, }), @@ -651,7 +666,7 @@ mod tests { "conversation_title": "Tasks", "created_by": "user", "execution_mode": "new_conversation", - "agent_config": {"backend": "acp", "name": "Claude", "assistant_id": "assistant-1"} + "agent_config": {"name": "Claude", "assistant_id": "assistant-1"} }); let req: CreateCronJobRequest = serde_json::from_value(raw).unwrap(); assert_eq!(req.name, "Daily task"); diff --git a/crates/aionui-app/src/router/team_conversation_adapters.rs b/crates/aionui-app/src/router/team_conversation_adapters.rs index abec70dda..59ed82266 100644 --- a/crates/aionui-app/src/router/team_conversation_adapters.rs +++ b/crates/aionui-app/src/router/team_conversation_adapters.rs @@ -234,7 +234,7 @@ impl TeamConversationProvisioningPort for TeamConversationAdapters { async fn conversation_assistant_id(&self, conversation_id: &str) -> Result, TeamError> { if let Some(snapshot) = self.conversation_repo.get_assistant_snapshot(conversation_id).await? { - let assistant_id = snapshot.assistant_key.trim(); + let assistant_id = snapshot.assistant_id.trim(); if !assistant_id.is_empty() { return Ok(Some(assistant_id.to_owned())); } diff --git a/crates/aionui-app/tests/assistants_e2e.rs b/crates/aionui-app/tests/assistants_e2e.rs index 6f7e747ed..8aa47a4da 100644 --- a/crates/aionui-app/tests/assistants_e2e.rs +++ b/crates/aionui-app/tests/assistants_e2e.rs @@ -55,7 +55,7 @@ struct Fixture { async fn insert_generated_bare_assistant( fx: &Fixture, - assistant_key: &str, + assistant_id: &str, source_ref: &str, backend: &str, name: &str, @@ -66,8 +66,8 @@ async fn insert_generated_bare_assistant( definition_repo .upsert(&UpsertAssistantDefinitionParams { - definition_id: &format!("asstdef-{assistant_key}"), - assistant_key, + id: &format!("asstdef-{assistant_id}"), + assistant_id, source: "generated", owner_type: "system", source_ref: Some(source_ref), @@ -100,7 +100,7 @@ async fn insert_generated_bare_assistant( .unwrap(); overlay_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: &format!("asstdef-{assistant_key}"), + assistant_definition_id: &format!("asstdef-{assistant_id}"), enabled: true, sort_order: 5, agent_id_override: None, @@ -454,12 +454,12 @@ async fn get_detail_returns_definition_state_preferences_and_rules() { let definition_repo = SqliteAssistantDefinitionRepository::new(pool.clone()); let state_repo = SqliteAssistantOverlayRepository::new(pool.clone()); let preference_repo = SqliteAssistantPreferenceRepository::new(pool); - let definition = definition_repo.get_by_key("u1").await.unwrap().unwrap(); + let definition = definition_repo.get_by_assistant_id("u1").await.unwrap().unwrap(); definition_repo .upsert(&UpsertAssistantDefinitionParams { - definition_id: &definition.definition_id, - assistant_key: &definition.assistant_key, + id: &definition.id, + assistant_id: &definition.assistant_id, source: &definition.source, owner_type: &definition.owner_type, source_ref: definition.source_ref.as_deref(), @@ -492,7 +492,7 @@ async fn get_detail_returns_definition_state_preferences_and_rules() { .unwrap(); state_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: &definition.definition_id, + assistant_definition_id: &definition.id, enabled: false, sort_order: 7, agent_id_override: Some("8e1acf31"), @@ -502,7 +502,7 @@ async fn get_detail_returns_definition_state_preferences_and_rules() { .unwrap(); preference_repo .upsert(&UpsertAssistantPreferenceParams { - definition_id: &definition.definition_id, + assistant_definition_id: &definition.id, last_model_id: Some("gpt-5-mini"), last_permission_value: Some("workspace-write"), last_skill_ids: r#"["pref-skill"]"#, diff --git a/crates/aionui-app/tests/conversation_e2e.rs b/crates/aionui-app/tests/conversation_e2e.rs index 7f85ad86f..29d8b5f58 100644 --- a/crates/aionui-app/tests/conversation_e2e.rs +++ b/crates/aionui-app/tests/conversation_e2e.rs @@ -150,12 +150,12 @@ async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { let state_repo = SqliteAssistantOverlayRepository::new(pool.clone()); let preference_repo = SqliteAssistantPreferenceRepository::new(pool); let conversation_repo = SqliteConversationRepository::new(services.database.pool().clone()); - let definition = definition_repo.get_by_key("u1").await.unwrap().unwrap(); + let definition = definition_repo.get_by_assistant_id("u1").await.unwrap().unwrap(); definition_repo .upsert(&UpsertAssistantDefinitionParams { - definition_id: &definition.definition_id, - assistant_key: &definition.assistant_key, + id: &definition.id, + assistant_id: &definition.assistant_id, source: &definition.source, owner_type: &definition.owner_type, source_ref: definition.source_ref.as_deref(), @@ -188,7 +188,7 @@ async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { .unwrap(); state_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: &definition.definition_id, + assistant_definition_id: &definition.id, enabled: true, sort_order: 0, agent_id_override: Some("8e1acf31"), @@ -198,7 +198,7 @@ async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { .unwrap(); preference_repo .upsert(&UpsertAssistantPreferenceParams { - definition_id: &definition.definition_id, + assistant_definition_id: &definition.id, last_model_id: Some("pref-model"), last_permission_value: Some("workspace-write"), last_skill_ids: r#"["pref-skill"]"#, @@ -257,14 +257,14 @@ async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { .await .unwrap() .unwrap(); - assert_eq!(snapshot.assistant_key, "u1"); + assert_eq!(snapshot.assistant_id, "u1"); assert_eq!(snapshot.agent_id, "8e1acf31"); assert_eq!(snapshot.rules_content, "assistant snapshot rule"); assert_eq!(snapshot.resolved_permission_value.as_deref(), Some("workspace-write")); assert_eq!(snapshot.resolved_skill_ids, r#"["override-skill"]"#); assert_eq!(snapshot.resolved_mcp_ids, r#"["override-mcp"]"#); - let updated_preference = preference_repo.get(&definition.definition_id).await.unwrap().unwrap(); + let updated_preference = preference_repo.get(&definition.id).await.unwrap().unwrap(); assert_eq!(updated_preference.last_model_id.as_deref(), Some("override-model")); assert_eq!( updated_preference.last_permission_value.as_deref(), diff --git a/crates/aionui-app/tests/cron_e2e.rs b/crates/aionui-app/tests/cron_e2e.rs index cb920f598..024b5ef29 100644 --- a/crates/aionui-app/tests/cron_e2e.rs +++ b/crates/aionui-app/tests/cron_e2e.rs @@ -354,7 +354,6 @@ async fn cj5b_run_now_legacy_workspace_with_whitespace_succeeds() { ), conversation_id: String::new(), conversation_title: None, - agent_type: "acp".into(), created_by: "user".into(), skill_content: None, description: None, @@ -778,7 +777,7 @@ async fn rn1c_run_now_new_conversation_preset_assistant_uses_fixed_assistant_mcp .await .expect("load assistant snapshot") .expect("preset assistant cron conversation should persist snapshot"); - assert_eq!(snapshot.assistant_key, "u-fixed-mcp"); + assert_eq!(snapshot.assistant_id, "u-fixed-mcp"); assert_eq!(snapshot.resolved_mcp_ids, json!([fixed_mcp.id]).to_string()); } diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 19907a9cc..19006b602 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -133,7 +133,7 @@ impl AssistantService { let default_disabled_builtin_skill_ids = serde_json::to_string(&builtin.disabled_builtin_skills) .map_err(|e| AssistantError::Internal(format!("encode builtin disabled skills: {e}")))?; let (avatar_type, avatar_value) = serialize_avatar("builtin", builtin.avatar.as_deref()); - let (definition_id, assistant_key) = self + let (definition_id, assistant_id) = self .resolve_definition_identity("builtin", Some(&builtin.id), &builtin.id) .await?; let agent_id = self @@ -142,8 +142,8 @@ impl AssistantService { self.definition_repo .upsert(&UpsertAssistantDefinitionParams { - definition_id: &definition_id, - assistant_key: &assistant_key, + id: &definition_id, + assistant_id: &assistant_id, source: "builtin", owner_type: "system", source_ref: Some(&builtin.id), @@ -198,7 +198,7 @@ impl AssistantService { let Some(source_ref) = definition.source_ref.as_deref() else { self.definition_repo - .soft_delete(&definition.definition_id, now_ms()) + .soft_delete(&definition.id, now_ms()) .await .map_err(|e| AssistantError::Internal(format!("soft-delete builtin definition: {e}")))?; continue; @@ -209,7 +209,7 @@ impl AssistantService { } self.definition_repo - .soft_delete(&definition.definition_id, now_ms()) + .soft_delete(&definition.id, now_ms()) .await .map_err(|e| AssistantError::Internal(format!("soft-delete builtin definition: {e}")))?; } @@ -229,7 +229,11 @@ impl AssistantService { async fn sync_legacy_overrides_to_new_states(&self) -> Result<(), AssistantError> { for override_row in self.override_repo.get_all().await? { - let Some(definition) = self.definition_repo.get_by_key(&override_row.assistant_id).await? else { + let Some(definition) = self + .definition_repo + .get_by_assistant_id(&override_row.assistant_id) + .await? + else { warn!( assistant_id = %override_row.assistant_id, "skip syncing assistant override without unified definition" @@ -244,7 +248,7 @@ impl AssistantService { self.state_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: &definition.definition_id, + assistant_definition_id: &definition.id, enabled: override_row.enabled, sort_order: override_row.sort_order, agent_id_override: agent_id_override.as_deref(), @@ -300,15 +304,15 @@ impl AssistantService { .filter(|row| !generated_source_refs.contains(&row.id)) .enumerate() { - let assistant_key = format!("bare:{}", row.id); - let (definition_id, assistant_key) = self - .resolve_definition_identity("generated", Some(&row.id), &assistant_key) + let assistant_id = format!("bare:{}", row.id); + let (definition_id, assistant_id) = self + .resolve_definition_identity("generated", Some(&row.id), &assistant_id) .await?; let avatar_value = row.icon.as_deref().filter(|value| !value.trim().is_empty()); self.definition_repo .upsert(&UpsertAssistantDefinitionParams { - definition_id: &definition_id, - assistant_key: &assistant_key, + id: &definition_id, + assistant_id: &assistant_id, source: "generated", owner_type: "system", source_ref: Some(&row.id), @@ -354,7 +358,7 @@ impl AssistantService { }; self.state_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: &definition_id, + assistant_definition_id: &definition_id, enabled: true, sort_order: initial_generated_sort_order.clamp(i32::MIN as i64, i32::MAX as i64) as i32, agent_id_override: None, @@ -381,15 +385,15 @@ impl AssistantService { let default_disabled_builtin_skill_ids = normalize_json_array_string(row.disabled_builtin_skills.as_deref(), "disabled_builtin_skills")?; let (avatar_type, avatar_value) = serialize_avatar("user", row.avatar.as_deref()); - let (definition_id, assistant_key) = self.resolve_definition_identity("user", Some(&row.id), &row.id).await?; + let (definition_id, assistant_id) = self.resolve_definition_identity("user", Some(&row.id), &row.id).await?; let agent_id = self .resolve_agent_id_for_runtime_backend(&row.preset_agent_type) .await?; self.definition_repo .upsert(&UpsertAssistantDefinitionParams { - definition_id: &definition_id, - assistant_key: &assistant_key, + id: &definition_id, + assistant_id: &assistant_id, source: "user", owner_type: "user", source_ref: Some(&row.id), @@ -436,7 +440,7 @@ impl AssistantService { let Some(existing) = self .definition_repo - .get_by_key(assistant_id) + .get_by_assistant_id(assistant_id) .await .map_err(|e| AssistantError::Internal(format!("get assistant definition: {e}")))? else { @@ -484,8 +488,8 @@ impl AssistantService { let patched = self .definition_repo .upsert(&UpsertAssistantDefinitionParams { - definition_id: &patched.definition_id, - assistant_key: &patched.assistant_key, + id: &patched.id, + assistant_id: &patched.assistant_id, source: &patched.source, owner_type: &patched.owner_type, source_ref: patched.source_ref.as_deref(), @@ -519,7 +523,7 @@ impl AssistantService { let state = self .state_repo - .get(&patched.definition_id) + .get(&patched.id) .await .map_err(|e| AssistantError::Internal(format!("get assistant overlay: {e}")))?; rebuild_legacy_assistant_mirror(&self.pool, &patched, state.as_ref()) @@ -538,7 +542,7 @@ impl AssistantService { .map_err(|e| AssistantError::Internal(format!("list assistant overlays: {e}")))?; let state_map: HashMap = states .into_iter() - .map(|state| (state.definition_id.clone(), state)) + .map(|state| (state.assistant_definition_id.clone(), state)) .collect(); for definition in self @@ -547,7 +551,7 @@ impl AssistantService { .await .map_err(|e| AssistantError::Internal(format!("list assistant definitions: {e}")))? { - rebuild_legacy_assistant_mirror(&self.pool, &definition, state_map.get(&definition.definition_id)) + rebuild_legacy_assistant_mirror(&self.pool, &definition, state_map.get(&definition.id)) .await .map_err(|e| AssistantError::Internal(format!("rebuild legacy mirror: {e}")))?; } @@ -564,7 +568,7 @@ impl AssistantService { if self.builtin.has(id) { return AssistantSource::Builtin; } - if let Ok(Some(definition)) = self.definition_repo.get_by_key(id).await { + if let Ok(Some(definition)) = self.definition_repo.get_by_assistant_id(id).await { return match definition.source.as_str() { "builtin" => AssistantSource::Builtin, "generated" => AssistantSource::Bare, @@ -595,18 +599,18 @@ impl AssistantService { .map_err(|e| AssistantError::Internal(format!("list assistant overlays: {e}")))?; let state_map: HashMap = states .into_iter() - .map(|state| (state.definition_id.clone(), state)) + .map(|state| (state.assistant_definition_id.clone(), state)) .collect(); let mut result = Vec::new(); for definition in &definitions { let projection = self - .project_definition(definition, state_map.get(&definition.definition_id), &projections) + .project_definition(definition, state_map.get(&definition.id), &projections) .await?; result.push(definition_to_response( definition, - state_map.get(&definition.definition_id), + state_map.get(&definition.id), &projection, )?); } @@ -630,8 +634,8 @@ impl AssistantService { pub async fn get(&self, id: &str) -> Result { let projections = self.reconcile_generated_assistants().await?; - if let Some(definition) = self.definition_repo.get_by_key(id).await? { - let state = self.state_repo.get(&definition.definition_id).await?; + if let Some(definition) = self.definition_repo.get_by_assistant_id(id).await? { + let state = self.state_repo.get(&definition.id).await?; let projection = self .project_definition(&definition, state.as_ref(), &projections) .await?; @@ -643,9 +647,9 @@ impl AssistantService { pub async fn get_detail(&self, id: &str, locale: Option<&str>) -> Result { let projections = self.reconcile_generated_assistants().await?; - if let Some(definition) = self.definition_repo.get_by_key(id).await? { - let state = self.state_repo.get(&definition.definition_id).await?; - let preference = self.preference_repo.get(&definition.definition_id).await?; + if let Some(definition) = self.definition_repo.get_by_assistant_id(id).await? { + let state = self.state_repo.get(&definition.id).await?; + let preference = self.preference_repo.get(&definition.id).await?; let rules_content = self.read_rule(id, locale).await?; let projection = self .project_definition(&definition, state.as_ref(), &projections) @@ -784,7 +788,7 @@ impl AssistantService { let row = self.repo.create(¶ms).await?; self.upsert_definition_from_legacy_user_row(&row).await?; self.apply_detail_overrides(&row.id, detail_overrides, false).await?; - if let Some(definition) = self.definition_repo.get_by_key(&row.id).await? { + if let Some(definition) = self.definition_repo.get_by_assistant_id(&row.id).await? { self.sync_preferences_from_defaults_request(&definition, None, req.defaults.as_ref()) .await?; } @@ -832,7 +836,7 @@ impl AssistantService { })?; let definition = self .definition_repo - .get_by_key(id) + .get_by_assistant_id(id) .await? .ok_or_else(|| AssistantError::NotFound(format!("assistant '{id}' not found")))?; @@ -843,7 +847,7 @@ impl AssistantService { let requested_agent_id = self.resolve_agent_id_for_runtime_backend(preset_agent_type).await?; let current_agent_id = self .state_repo - .get(&definition.definition_id) + .get(&definition.id) .await .map_err(|e| AssistantError::Internal(format!("get assistant overlay: {e}")))? .and_then(|row| row.agent_id_override) @@ -851,7 +855,7 @@ impl AssistantService { let reset_model_and_permission = current_agent_id != requested_agent_id; self.state_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: &definition.definition_id, + assistant_definition_id: &definition.id, enabled, sort_order, agent_id_override: Some(&requested_agent_id), @@ -863,12 +867,12 @@ impl AssistantService { .await?; let definition = self .definition_repo - .get_by_key(id) + .get_by_assistant_id(id) .await? .ok_or_else(|| AssistantError::NotFound(format!("assistant '{id}' not found")))?; self.sync_preferences_from_defaults_request(&definition, Some(&definition), req.defaults.as_ref()) .await?; - let state = self.state_repo.get(&definition.definition_id).await?; + let state = self.state_repo.get(&definition.id).await?; rebuild_legacy_assistant_mirror(&self.pool, &definition, state.as_ref()) .await .map_err(|e| AssistantError::Internal(format!("rebuild legacy mirror: {e}")))?; @@ -886,7 +890,7 @@ impl AssistantService { let detail_overrides = SerializedDetailOverrides::from_update(&req)?; let current_definition = self .definition_repo - .get_by_key(id) + .get_by_assistant_id(id) .await? .ok_or_else(|| AssistantError::NotFound(format!("assistant '{id}' not found")))?; let requested_agent_id = match req.preset_agent_type.as_deref() { @@ -924,7 +928,7 @@ impl AssistantService { self.upsert_definition_from_legacy_user_row(&row).await?; self.apply_detail_overrides(id, detail_overrides, reset_model_and_permission) .await?; - if let Some(definition) = self.definition_repo.get_by_key(id).await? { + if let Some(definition) = self.definition_repo.get_by_assistant_id(id).await? { self.sync_preferences_from_defaults_request(&definition, Some(¤t_definition), req.defaults.as_ref()) .await?; } @@ -943,7 +947,7 @@ impl AssistantService { let existing = self .preference_repo - .get(&definition.definition_id) + .get(&definition.id) .await .map_err(|e| AssistantError::Internal(format!("get assistant preference: {e}")))?; @@ -1047,7 +1051,7 @@ impl AssistantService { { if existing.is_some() { self.preference_repo - .delete(&definition.definition_id) + .delete(&definition.id) .await .map_err(|e| AssistantError::Internal(format!("delete assistant preference: {e}")))?; } @@ -1063,7 +1067,7 @@ impl AssistantService { self.preference_repo .upsert(&UpsertAssistantPreferenceParams { - definition_id: &definition.definition_id, + assistant_definition_id: &definition.id, last_model_id: last_model_id.as_deref(), last_permission_value: last_permission_value.as_deref(), last_skill_ids: &last_skill_ids_json, @@ -1096,18 +1100,14 @@ impl AssistantService { if let Err(e) = self.override_repo.delete(id).await { warn!("failed to remove override for deleted assistant '{id}': {e}"); } - if let Some(definition) = self.definition_repo.get_by_key(id).await? { - if let Err(e) = self.state_repo.delete(&definition.definition_id).await { + if let Some(definition) = self.definition_repo.get_by_assistant_id(id).await? { + if let Err(e) = self.state_repo.delete(&definition.id).await { warn!("failed to remove assistant overlay for deleted assistant '{id}': {e}"); } - if let Err(e) = self.preference_repo.delete(&definition.definition_id).await { + if let Err(e) = self.preference_repo.delete(&definition.id).await { warn!("failed to remove assistant preferences for deleted assistant '{id}': {e}"); } - if let Err(e) = self - .definition_repo - .soft_delete(&definition.definition_id, now_ms()) - .await - { + if let Err(e) = self.definition_repo.soft_delete(&definition.id, now_ms()).await { warn!("failed to soft-delete assistant definition for deleted assistant '{id}': {e}"); } } @@ -1136,10 +1136,10 @@ impl AssistantService { // Merge with existing state/override to preserve fields not in this request. let definition = self .definition_repo - .get_by_key(id) + .get_by_assistant_id(id) .await? .ok_or_else(|| AssistantError::NotFound(format!("assistant '{id}' not found")))?; - let existing_state = self.state_repo.get(&definition.definition_id).await?; + let existing_state = self.state_repo.get(&definition.id).await?; let existing = self.override_repo.get(id).await?; let enabled = req.enabled.unwrap_or_else(|| { existing_state @@ -1170,7 +1170,7 @@ impl AssistantService { let state = self .state_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: &definition.definition_id, + assistant_definition_id: &definition.id, enabled, sort_order, agent_id_override: agent_id_override.as_deref(), @@ -1594,7 +1594,7 @@ impl AssistantService { &self, source: &str, source_ref: Option<&str>, - assistant_key: &str, + assistant_id: &str, ) -> Result<(String, String), AssistantError> { if let Some(source_ref) = source_ref && let Some(existing) = self @@ -1603,19 +1603,19 @@ impl AssistantService { .await .map_err(|e| AssistantError::Internal(format!("get assistant definition by source_ref: {e}")))? { - return Ok((existing.definition_id, existing.assistant_key)); + return Ok((existing.id, existing.assistant_id)); } if let Some(existing) = self .definition_repo - .get_by_key(assistant_key) + .get_by_assistant_id(assistant_id) .await .map_err(|e| AssistantError::Internal(format!("get assistant definition by key: {e}")))? { - return Ok((existing.definition_id, existing.assistant_key)); + return Ok((existing.id, existing.assistant_id)); } - Ok((generate_prefixed_id("asstdef"), assistant_key.to_string())) + Ok((generate_prefixed_id("asstdef"), assistant_id.to_string())) } fn cleanup_user_assets(&self, id: &str) { @@ -1690,7 +1690,7 @@ fn avatar_display_value(definition: &AssistantDefinitionRow) -> Option { if is_direct_avatar_url(value) { value.to_string() } else { - format!("/api/assistants/{}/avatar", definition.assistant_key) + format!("/api/assistants/{}/avatar", definition.assistant_id) } }), _ => definition.avatar_value.clone(), @@ -1768,7 +1768,7 @@ fn definition_to_response( }; Ok(AssistantResponse { - id: definition.assistant_key.clone(), + id: definition.assistant_id.clone(), source, name: definition.name.clone(), name_i18n: decode_str_map(Some(definition.name_i18n.as_str()))?, @@ -1821,7 +1821,7 @@ fn definition_to_detail_response( .unwrap_or_default(); Ok(AssistantDetailResponse { - id: definition.assistant_key.clone(), + id: definition.assistant_id.clone(), source: match definition.source.as_str() { "builtin" => AssistantSource::Builtin, "generated" => AssistantSource::Bare, @@ -2558,8 +2558,8 @@ mod tests { let fx = fixture().await; fx.definition_repo .upsert(&UpsertAssistantDefinitionParams { - definition_id: "asstdef-generated", - assistant_key: "bare:claude", + id: "asstdef-generated", + assistant_id: "bare:claude", source: "generated", owner_type: "system", source_ref: Some("agent-claude"), @@ -2592,7 +2592,7 @@ mod tests { .unwrap(); fx.state_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: "asstdef-generated", + assistant_definition_id: "asstdef-generated", enabled: true, sort_order: 3, agent_id_override: None, @@ -2794,13 +2794,18 @@ mod tests { fx.service.bootstrap_assistant_storage().await.unwrap(); - let builtin = fx.definition_repo.get_by_key("builtin-office").await.unwrap().unwrap(); + let builtin = fx + .definition_repo + .get_by_assistant_id("builtin-office") + .await + .unwrap() + .unwrap(); assert_eq!(builtin.source, "builtin"); assert_eq!(builtin.rule_resource_type, "builtin_asset"); assert_eq!(builtin.rule_resource_ref.as_deref(), Some("builtin-office")); - let user = fx.definition_repo.get_by_key("u1").await.unwrap().unwrap(); + let user = fx.definition_repo.get_by_assistant_id("u1").await.unwrap().unwrap(); assert_eq!(user.source, "user"); - let builtin_state = fx.state_repo.get(&builtin.definition_id).await.unwrap().unwrap(); + let builtin_state = fx.state_repo.get(&builtin.id).await.unwrap().unwrap(); assert!(!builtin_state.enabled); assert_eq!(builtin_state.sort_order, 9); assert_eq!(builtin_state.last_used_at, Some(1234)); @@ -2810,27 +2815,32 @@ mod tests { async fn bootstrap_soft_deletes_builtin_removed_from_manifest() { let mut fx = fixture_with_builtins(vec![mk_builtin("builtin-office", "Office")]).await; - let original = fx.definition_repo.get_by_key("builtin-office").await.unwrap().unwrap(); + let original = fx + .definition_repo + .get_by_assistant_id("builtin-office") + .await + .unwrap() + .unwrap(); fx.service.builtin = Arc::new(BuiltinAssistantRegistry::empty()); fx.service.bootstrap_assistant_storage().await.unwrap(); - assert!(fx.definition_repo.get_by_key("builtin-office").await.unwrap().is_none()); assert!( - fx.service - .list() + fx.definition_repo + .get_by_assistant_id("builtin-office") .await .unwrap() - .iter() - .all(|assistant| assistant.id != "builtin-office") + .is_none() ); assert!( - fx.definition_repo - .get_by_definition_id(&original.definition_id) + fx.service + .list() .await .unwrap() - .is_none() + .iter() + .all(|assistant| assistant.id != "builtin-office") ); + assert!(fx.definition_repo.get_by_id(&original.id).await.unwrap().is_none()); } #[tokio::test] @@ -2859,7 +2869,7 @@ mod tests { .await .unwrap(); - let definition = fx.definition_repo.get_by_key("u1").await.unwrap().unwrap(); + let definition = fx.definition_repo.get_by_assistant_id("u1").await.unwrap().unwrap(); assert_eq!(definition.name_i18n, "{}"); assert_eq!(definition.description_i18n, "{}"); assert_eq!(definition.recommended_prompts_i18n, "{}"); @@ -3305,13 +3315,8 @@ mod tests { .await .unwrap(); - let definition = fx.definition_repo.get_by_key("u1").await.unwrap().unwrap(); - let pref = fx - .preference_repo - .get(&definition.definition_id) - .await - .unwrap() - .unwrap(); + let definition = fx.definition_repo.get_by_assistant_id("u1").await.unwrap().unwrap(); + let pref = fx.preference_repo.get(&definition.id).await.unwrap().unwrap(); assert_eq!(pref.last_model_id.as_deref(), Some("openai/gpt-5")); assert_eq!(pref.last_permission_value.as_deref(), Some("strict")); assert_eq!(pref.last_skill_ids, r#"["skill-z"]"#); @@ -3376,14 +3381,8 @@ mod tests { .await .unwrap(); - let definition = fx.definition_repo.get_by_key("u1").await.unwrap().unwrap(); - assert!( - fx.preference_repo - .get(&definition.definition_id) - .await - .unwrap() - .is_none() - ); + let definition = fx.definition_repo.get_by_assistant_id("u1").await.unwrap().unwrap(); + assert!(fx.preference_repo.get(&definition.id).await.unwrap().is_none()); } #[tokio::test] diff --git a/crates/aionui-channel/src/channel_settings.rs b/crates/aionui-channel/src/channel_settings.rs index 222d45415..051524c52 100644 --- a/crates/aionui-channel/src/channel_settings.rs +++ b/crates/aionui-channel/src/channel_settings.rs @@ -248,12 +248,12 @@ impl ChannelSettingsService { return Ok(None); }; - let Some(definition) = definition_repo.get_by_key(assistant_id).await? else { + let Some(definition) = definition_repo.get_by_assistant_id(assistant_id).await? else { return Ok(None); }; let agent_id = overlay_repo - .get(&definition.definition_id) + .get(&definition.id) .await? .and_then(|row| row.agent_id_override) .unwrap_or(definition.agent_id); @@ -292,7 +292,7 @@ impl ChannelSettingsService { } let runtime_backend = self.runtime_backend_for_agent_id(&definition.agent_id).await?; if runtime_backend == legacy_backend { - return Ok(Some(definition.assistant_key)); + return Ok(Some(definition.assistant_id)); } } @@ -363,7 +363,7 @@ impl ChannelSettingsService { for definition in definitions.iter().filter(|definition| definition.source == "generated") { if self.effective_assistant_backend(definition, &overlays).await? == DEFAULT_AGENT_TYPE { - return Ok(Some(definition.assistant_key.clone())); + return Ok(Some(definition.assistant_id.clone())); } } @@ -375,7 +375,7 @@ impl ChannelSettingsService { } } if let Some(definition) = any_aionrs { - return Ok(Some(definition.assistant_key.clone())); + return Ok(Some(definition.assistant_id.clone())); } Ok(None) @@ -388,7 +388,7 @@ impl ChannelSettingsService { ) -> Result { let agent_id = overlays .iter() - .find(|overlay| overlay.definition_id == definition.definition_id) + .find(|overlay| overlay.assistant_definition_id == definition.id) .and_then(|overlay| overlay.agent_id_override.as_deref()) .unwrap_or(definition.agent_id.as_str()); self.runtime_backend_for_agent_id(agent_id).await @@ -580,12 +580,12 @@ mod tests { Ok(self.rows.clone()) } - async fn get_by_key(&self, assistant_key: &str) -> Result, DbError> { - Ok(self.rows.iter().find(|row| row.assistant_key == assistant_key).cloned()) + async fn get_by_assistant_id(&self, assistant_id: &str) -> Result, DbError> { + Ok(self.rows.iter().find(|row| row.assistant_id == assistant_id).cloned()) } - async fn get_by_definition_id(&self, definition_id: &str) -> Result, DbError> { - Ok(self.rows.iter().find(|row| row.definition_id == definition_id).cloned()) + async fn get_by_id(&self, definition_id: &str) -> Result, DbError> { + Ok(self.rows.iter().find(|row| row.id == definition_id).cloned()) } async fn get_by_source_ref( @@ -619,7 +619,11 @@ mod tests { #[async_trait::async_trait] impl IAssistantOverlayRepository for MockAssistantOverlayRepo { async fn get(&self, definition_id: &str) -> Result, DbError> { - Ok(self.rows.iter().find(|row| row.definition_id == definition_id).cloned()) + Ok(self + .rows + .iter() + .find(|row| row.assistant_definition_id == definition_id) + .cloned()) } async fn list(&self) -> Result, DbError> { @@ -635,16 +639,16 @@ mod tests { } } - fn make_definition(assistant_key: &str, agent_id: &str) -> AssistantDefinitionRow { + fn make_definition(assistant_id: &str, agent_id: &str) -> AssistantDefinitionRow { AssistantDefinitionRow { - definition_id: format!("def-{assistant_key}"), - assistant_key: assistant_key.to_owned(), + id: format!("def-{assistant_id}"), + assistant_id: assistant_id.to_owned(), source: "generated".to_owned(), owner_type: "system".to_owned(), - source_ref: Some(assistant_key.to_owned()), + source_ref: Some(assistant_id.to_owned()), source_version: None, source_hash: None, - name: assistant_key.to_owned(), + name: assistant_id.to_owned(), name_i18n: "{}".to_owned(), description: None, description_i18n: "{}".to_owned(), @@ -674,7 +678,7 @@ mod tests { fn make_overlay(definition_id: &str, agent_id_override: &str) -> AssistantOverlayRow { AssistantOverlayRow { - definition_id: definition_id.to_owned(), + assistant_definition_id: definition_id.to_owned(), enabled: true, sort_order: 0, agent_id_override: Some(agent_id_override.to_owned()), @@ -828,7 +832,7 @@ mod tests { rows: vec![definition.clone()], }); let overlay_repo: Arc = Arc::new(MockAssistantOverlayRepo { - rows: vec![make_overlay(&definition.definition_id, "codex")], + rows: vec![make_overlay(&definition.id, "codex")], }); let svc = ChannelSettingsService::new(repo).with_assistant_repos(definition_repo, overlay_repo); diff --git a/crates/aionui-channel/tests/message_service_integration.rs b/crates/aionui-channel/tests/message_service_integration.rs index 93ebeb760..27a49d7cb 100644 --- a/crates/aionui-channel/tests/message_service_integration.rs +++ b/crates/aionui-channel/tests/message_service_integration.rs @@ -183,18 +183,18 @@ impl IWorkerTaskManager for RecordingTaskManager { fn bare_assistant_definition_params<'a>( definition_id: &'a str, - assistant_key: &'a str, + assistant_id: &'a str, agent_id: &'a str, ) -> UpsertAssistantDefinitionParams<'a> { UpsertAssistantDefinitionParams { - definition_id, - assistant_key, + id: definition_id, + assistant_id, source: "generated", owner_type: "system", - source_ref: Some(assistant_key), + source_ref: Some(assistant_id), source_version: None, source_hash: None, - name: assistant_key, + name: assistant_id, name_i18n: "{}", description: Some("Channel bare assistant"), description_i18n: "{}", @@ -356,7 +356,7 @@ async fn send_to_agent_persists_assistant_snapshot_for_channel_bound_assistant() .expect("acp_session row should exist for ACP assistant conversations"); assert_eq!(session_row.agent_backend, "claude"); assert!(!session_row.agent_id.is_empty()); - assert_eq!(snapshot.assistant_key, "bare-claude"); + assert_eq!(snapshot.assistant_id, "bare-claude"); assert_eq!(snapshot.agent_id, "2d23ff1c"); assert_eq!(conversation.name, "Claude"); } @@ -486,7 +486,7 @@ async fn send_to_agent_without_saved_binding_defaults_to_bare_aionrs_assistant() .expect("channel-created conversation should default to a bare assistant snapshot"); let conversation = conversation_repo.get(&result.conversation_id).await.unwrap().unwrap(); - assert_eq!(snapshot.assistant_key, "bare-aionrs"); + assert_eq!(snapshot.assistant_id, "bare-aionrs"); assert_eq!(snapshot.agent_id, "632f31d2"); assert_eq!(conversation.r#type, AgentType::Aionrs.serde_name()); assert_eq!(conversation.name, "tg-aionrs-70880480"); diff --git a/crates/aionui-common/src/types.rs b/crates/aionui-common/src/types.rs index ce104b763..cb9168c1d 100644 --- a/crates/aionui-common/src/types.rs +++ b/crates/aionui-common/src/types.rs @@ -40,7 +40,7 @@ pub enum UpdateType { } /// Model selection config — references a provider and a specific model. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ProviderWithModel { pub provider_id: String, pub model: String, diff --git a/crates/aionui-conversation/src/convert.rs b/crates/aionui-conversation/src/convert.rs index eff7e263d..ac02bae19 100644 --- a/crates/aionui-conversation/src/convert.rs +++ b/crates/aionui-conversation/src/convert.rs @@ -87,12 +87,12 @@ pub fn snapshot_to_assistant_identity( runtime_backend: &str, ) -> ConversationAssistantIdentityResponse { let avatar = match snapshot.assistant_avatar_type.as_str() { - "builtin_asset" | "user_asset" => format!("/api/assistants/{}/avatar", snapshot.assistant_key), + "builtin_asset" | "user_asset" => format!("/api/assistants/{}/avatar", snapshot.assistant_id), _ => snapshot.assistant_avatar_value.clone().unwrap_or_default(), }; ConversationAssistantIdentityResponse { - id: snapshot.assistant_key.clone(), + id: snapshot.assistant_id.clone(), source: snapshot.assistant_source.clone(), name: snapshot.assistant_name.clone(), avatar, diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index e9f4e0944..12d6cd6be 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -1050,7 +1050,7 @@ impl ConversationService { .upsert_assistant_snapshot(&UpsertConversationAssistantSnapshotParams { conversation_id: &row.id, assistant_definition_id: &snapshot.assistant_definition_id, - assistant_key: &snapshot.assistant_id, + assistant_id: &snapshot.assistant_id, assistant_source: &snapshot.assistant_source, assistant_name: &snapshot.name, assistant_avatar_type: &snapshot.avatar_type, @@ -1223,7 +1223,7 @@ impl ConversationService { }; let Some(definition) = definition_repo - .get_by_key(assistant_id) + .get_by_assistant_id(assistant_id) .await .map_err(|e| ConversationError::internal(format!("assistant definition lookup failed: {e}")))? else { @@ -1231,11 +1231,11 @@ impl ConversationService { }; let state = state_repo - .get(&definition.definition_id) + .get(&definition.id) .await .map_err(|e| ConversationError::internal(format!("assistant state lookup failed: {e}")))?; let preference = preference_repo - .get(&definition.definition_id) + .get(&definition.id) .await .map_err(|e| ConversationError::internal(format!("assistant preference lookup failed: {e}")))?; @@ -1316,7 +1316,7 @@ impl ConversationService { let agent_binding = self.resolve_assistant_agent_binding(&effective_agent_id).await?; Ok(Some(AssistantSnapshot { - assistant_definition_id: definition.definition_id, + assistant_definition_id: definition.id, assistant_id: assistant_id.to_owned(), assistant_source: definition.source, name: definition.name, @@ -1411,7 +1411,7 @@ impl ConversationService { preference_repo .upsert(&aionui_db::UpsertAssistantPreferenceParams { - definition_id: &snapshot.assistant_definition_id, + assistant_definition_id: &snapshot.assistant_definition_id, last_model_id: last_model_id.as_deref(), last_permission_value: last_permission_value.as_deref(), last_skill_ids: &last_skill_ids, @@ -1446,7 +1446,7 @@ impl ConversationService { .upsert_assistant_snapshot(&UpsertConversationAssistantSnapshotParams { conversation_id: &snapshot.conversation_id, assistant_definition_id: &snapshot.assistant_definition_id, - assistant_key: &snapshot.assistant_key, + assistant_id: &snapshot.assistant_id, assistant_source: &snapshot.assistant_source, assistant_name: &snapshot.assistant_name, assistant_avatar_type: &snapshot.assistant_avatar_type, @@ -1529,7 +1529,7 @@ impl ConversationService { return Ok(()); }; let Some(definition) = definition_repo - .get_by_key(&assistant_id) + .get_by_assistant_id(&assistant_id) .await .map_err(|e| ConversationError::internal(format!("assistant definition lookup failed: {e}")))? else { @@ -1553,7 +1553,7 @@ impl ConversationService { .as_ref() .ok_or_else(|| ConversationError::internal("assistant preference sync fallback missing"))?; ( - definition.definition_id.clone(), + definition.id.clone(), legacy_snapshot .as_ref() .map(|value| assistant_snapshot_modes(value, definition)) @@ -1591,7 +1591,7 @@ impl ConversationService { preference_repo .upsert(&aionui_db::UpsertAssistantPreferenceParams { - definition_id: &definition_id, + assistant_definition_id: &definition_id, last_model_id: last_model_id.as_deref(), last_permission_value: last_permission_value.as_deref(), last_skill_ids: existing_preference diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 590ed0f7a..3953c628b 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -334,7 +334,7 @@ impl IConversationRepository for MockRepo { let row = ConversationAssistantSnapshotRow { conversation_id: params.conversation_id.to_owned(), assistant_definition_id: params.assistant_definition_id.to_owned(), - assistant_key: params.assistant_key.to_owned(), + assistant_id: params.assistant_id.to_owned(), assistant_source: params.assistant_source.to_owned(), assistant_name: params.assistant_name.to_owned(), assistant_avatar_type: params.assistant_avatar_type.to_owned(), @@ -947,20 +947,20 @@ fn ensure_test_workspace_path() -> String { async fn upsert_test_assistant_definition( repo: &SqliteAssistantDefinitionRepository, definition_id: &str, - assistant_key: &str, + assistant_id: &str, agent_id: &str, default_model_mode: &str, default_permission_mode: &str, ) { repo.upsert(&UpsertAssistantDefinitionParams { - definition_id, - assistant_key, + id: definition_id, + assistant_id, source: "builtin", owner_type: "system", - source_ref: Some(assistant_key), + source_ref: Some(assistant_id), source_version: None, source_hash: None, - name: assistant_key, + name: assistant_id, name_i18n: "{}", description: Some("desc"), description_i18n: "{}", @@ -968,7 +968,7 @@ async fn upsert_test_assistant_definition( avatar_value: Some("🤖"), agent_id, rule_resource_type: "builtin_asset", - rule_resource_ref: Some(assistant_key), + rule_resource_ref: Some(assistant_id), rule_inline_content: None, recommended_prompts: "[]", recommended_prompts_i18n: "{}", @@ -1228,7 +1228,7 @@ async fn create_derives_aionrs_type_from_assistant_backend_when_type_is_missing( .await; overlay_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: "asstdef_aionrs_missing_type", + assistant_definition_id: "asstdef_aionrs_missing_type", enabled: true, sort_order: 0, agent_id_override: None, @@ -1279,7 +1279,7 @@ async fn create_derives_acp_type_from_assistant_backend_when_type_is_missing() { .await; overlay_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: "asstdef_acp_missing_type", + assistant_definition_id: "asstdef_acp_missing_type", enabled: true, sort_order: 0, agent_id_override: None, @@ -2867,7 +2867,7 @@ async fn set_config_option_persists_runtime_model_into_assistant_preference_when .await; overlay_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: "asstdef_acp_auto", + assistant_definition_id: "asstdef_acp_auto", enabled: true, sort_order: 0, agent_id_override: None, @@ -2877,7 +2877,7 @@ async fn set_config_option_persists_runtime_model_into_assistant_preference_when .unwrap(); preference_repo .upsert(&UpsertAssistantPreferenceParams { - definition_id: "asstdef_acp_auto", + assistant_definition_id: "asstdef_acp_auto", last_model_id: Some("legacy-acp-model"), last_permission_value: Some("legacy-mode"), last_skill_ids: "[]", @@ -2957,7 +2957,7 @@ async fn set_config_option_skips_preference_write_back_when_default_mode_is_fixe .await; overlay_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: "asstdef_acp_fixed", + assistant_definition_id: "asstdef_acp_fixed", enabled: true, sort_order: 0, agent_id_override: None, @@ -2967,7 +2967,7 @@ async fn set_config_option_skips_preference_write_back_when_default_mode_is_fixe .unwrap(); preference_repo .upsert(&UpsertAssistantPreferenceParams { - definition_id: "asstdef_acp_fixed", + assistant_definition_id: "asstdef_acp_fixed", last_model_id: Some("legacy-fixed-model"), last_permission_value: Some("legacy-fixed-mode"), last_skill_ids: "[]", @@ -3027,7 +3027,7 @@ async fn set_config_option_command_ack_does_not_persist_assistant_preference() { .await; overlay_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: "asstdef_acp_ack", + assistant_definition_id: "asstdef_acp_ack", enabled: true, sort_order: 0, agent_id_override: None, @@ -3037,7 +3037,7 @@ async fn set_config_option_command_ack_does_not_persist_assistant_preference() { .unwrap(); preference_repo .upsert(&UpsertAssistantPreferenceParams { - definition_id: "asstdef_acp_ack", + assistant_definition_id: "asstdef_acp_ack", last_model_id: Some("legacy-ack-model"), last_permission_value: Some("legacy-ack-mode"), last_skill_ids: "[]", @@ -3090,7 +3090,7 @@ async fn update_aionrs_model_updates_assistant_preference_only_when_snapshot_mod .await; overlay_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: "asstdef_aionrs_auto", + assistant_definition_id: "asstdef_aionrs_auto", enabled: true, sort_order: 0, agent_id_override: None, @@ -3100,7 +3100,7 @@ async fn update_aionrs_model_updates_assistant_preference_only_when_snapshot_mod .unwrap(); preference_repo .upsert(&UpsertAssistantPreferenceParams { - definition_id: "asstdef_aionrs_auto", + assistant_definition_id: "asstdef_aionrs_auto", last_model_id: Some("legacy-aionrs-model"), last_permission_value: None, last_skill_ids: "[]", @@ -3151,7 +3151,7 @@ async fn update_aionrs_model_updates_assistant_preference_only_when_snapshot_mod .await; overlay_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: "asstdef_aionrs_fixed", + assistant_definition_id: "asstdef_aionrs_fixed", enabled: true, sort_order: 0, agent_id_override: None, @@ -3161,7 +3161,7 @@ async fn update_aionrs_model_updates_assistant_preference_only_when_snapshot_mod .unwrap(); preference_repo .upsert(&UpsertAssistantPreferenceParams { - definition_id: "asstdef_aionrs_fixed", + assistant_definition_id: "asstdef_aionrs_fixed", last_model_id: Some("legacy-aionrs-fixed-model"), last_permission_value: None, last_skill_ids: "[]", @@ -4698,8 +4698,8 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { definition_repo .upsert(&UpsertAssistantDefinitionParams { - definition_id: "asstdef_preset_1", - assistant_key: "preset-1", + id: "asstdef_preset_1", + assistant_id: "preset-1", source: "builtin", owner_type: "system", source_ref: Some("preset-1"), @@ -4732,7 +4732,7 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { .unwrap(); state_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: "asstdef_preset_1", + assistant_definition_id: "asstdef_preset_1", enabled: true, sort_order: 0, agent_id_override: Some("codex"), @@ -4742,7 +4742,7 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { .unwrap(); preference_repo .upsert(&UpsertAssistantPreferenceParams { - definition_id: "asstdef_preset_1", + assistant_definition_id: "asstdef_preset_1", last_model_id: Some("old-model"), last_permission_value: Some("workspace-write"), last_skill_ids: r#"["legacy-skill"]"#, @@ -4796,7 +4796,7 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { let snapshot = repo.get_assistant_snapshot(&resp.id).await.unwrap().unwrap(); assert_eq!(snapshot.assistant_definition_id, "asstdef_preset_1"); - assert_eq!(snapshot.assistant_key, "preset-1"); + assert_eq!(snapshot.assistant_id, "preset-1"); assert_eq!(snapshot.agent_id, "8e1acf31"); assert_eq!(snapshot.rules_content, "assistant rule body"); assert_eq!(snapshot.default_model_mode, "auto"); @@ -4860,8 +4860,8 @@ async fn create_prefers_assistant_snapshot_over_legacy_runtime_seed_fields() { definition_repo .upsert(&UpsertAssistantDefinitionParams { - definition_id: "asstdef_preset_legacy_seed", - assistant_key: "preset-1", + id: "asstdef_preset_legacy_seed", + assistant_id: "preset-1", source: "builtin", owner_type: "system", source_ref: Some("preset-1"), @@ -4894,7 +4894,7 @@ async fn create_prefers_assistant_snapshot_over_legacy_runtime_seed_fields() { .unwrap(); state_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: "asstdef_preset_legacy_seed", + assistant_definition_id: "asstdef_preset_legacy_seed", enabled: true, sort_order: 0, agent_id_override: Some("codex"), @@ -4904,7 +4904,7 @@ async fn create_prefers_assistant_snapshot_over_legacy_runtime_seed_fields() { .unwrap(); preference_repo .upsert(&UpsertAssistantPreferenceParams { - definition_id: "asstdef_preset_legacy_seed", + assistant_definition_id: "asstdef_preset_legacy_seed", last_model_id: Some("preferred-model"), last_permission_value: Some("workspace-write"), last_skill_ids: r#"["legacy-skill"]"#, @@ -4966,7 +4966,7 @@ async fn create_prefers_snapshot_runtime_identity_over_legacy_extra_identity() { .await; overlay_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: "asstdef_snapshot_identity", + assistant_definition_id: "asstdef_snapshot_identity", enabled: true, sort_order: 0, agent_id_override: None, @@ -5021,8 +5021,8 @@ async fn create_does_not_overwrite_preferences_for_fixed_skills_and_mcps() { definition_repo .upsert(&UpsertAssistantDefinitionParams { - definition_id: "asstdef_preset_fixed", - assistant_key: "preset-fixed", + id: "asstdef_preset_fixed", + assistant_id: "preset-fixed", source: "builtin", owner_type: "system", source_ref: Some("preset-fixed"), @@ -5055,7 +5055,7 @@ async fn create_does_not_overwrite_preferences_for_fixed_skills_and_mcps() { .unwrap(); state_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: "asstdef_preset_fixed", + assistant_definition_id: "asstdef_preset_fixed", enabled: true, sort_order: 0, agent_id_override: Some("codex"), @@ -5065,7 +5065,7 @@ async fn create_does_not_overwrite_preferences_for_fixed_skills_and_mcps() { .unwrap(); preference_repo .upsert(&UpsertAssistantPreferenceParams { - definition_id: "asstdef_preset_fixed", + assistant_definition_id: "asstdef_preset_fixed", last_model_id: Some("legacy-model"), last_permission_value: Some("workspace-write"), last_skill_ids: r#"["legacy-skill"]"#, @@ -5119,8 +5119,8 @@ async fn create_with_auto_builtin_defaults_without_preferences_keeps_snapshot_va definition_repo .upsert(&UpsertAssistantDefinitionParams { - definition_id: "asstdef_preset_auto", - assistant_key: "preset-auto", + id: "asstdef_preset_auto", + assistant_id: "preset-auto", source: "builtin", owner_type: "system", source_ref: Some("preset-auto"), @@ -5153,7 +5153,7 @@ async fn create_with_auto_builtin_defaults_without_preferences_keeps_snapshot_va .unwrap(); state_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: "asstdef_preset_auto", + assistant_definition_id: "asstdef_preset_auto", enabled: true, sort_order: 0, agent_id_override: Some("codex"), @@ -5163,7 +5163,7 @@ async fn create_with_auto_builtin_defaults_without_preferences_keeps_snapshot_va .unwrap(); preference_repo .upsert(&UpsertAssistantPreferenceParams { - definition_id: "asstdef_preset_auto", + assistant_definition_id: "asstdef_preset_auto", last_model_id: None, last_permission_value: None, last_skill_ids: "[]", diff --git a/crates/aionui-cron/src/executor.rs b/crates/aionui-cron/src/executor.rs index 6cc72e14d..e466b7bdb 100644 --- a/crates/aionui-cron/src/executor.rs +++ b/crates/aionui-cron/src/executor.rs @@ -984,47 +984,24 @@ async fn parse_agent_type(registry: &AgentRegistry, agent_type_str: &str) -> Res /// `CreateConversationRequest.model` stay `None` for those types, which is the /// correct semantic. /// -/// For aionrs, `agent_config.backend` holds the provider_id (a DB hash, not a -/// vendor label). `CronService::add_job`/`update_job` already rejects aionrs -/// jobs lacking this field, so the `None` return here is defensive for any -/// legacy in-memory row that somehow slipped through. fn resolve_model(job: &CronJob) -> Option { if job.agent_type != "aionrs" { return None; } - let config = job.agent_config.as_ref()?; - if config.backend.trim().is_empty() { - return None; - } - Some(ProviderWithModel { - provider_id: config.backend.clone(), - model: config.model_id.clone().unwrap_or_else(|| "default".to_owned()), - use_model: None, - }) + job.agent_config.as_ref()?.model.clone() } /// Fill `extra` with the agent identity the factory should use. /// /// Preferred path: resolve a builtin ACP catalog row via the /// registry and emit `agent_id` (exact factory lookup) alongside -/// `backend` (convenience for other consumers). Legacy path: when -/// `agent_config.backend` names something that isn't a builtin ACP -/// vendor (e.g. the bare string `"acp"` that old rows still carry), -/// pass it through unchanged so the factory's agent-type branch can -/// handle it. Same treatment for `agent_type` when there is no -/// `agent_config` but the stored type matches a vendor label. +/// `backend` (convenience for other consumers). async fn inject_agent_identity( extra: &mut serde_json::Map, registry: &AgentRegistry, job: &CronJob, ) { - let config_backend = job - .agent_config - .as_ref() - .map(|c| c.backend.trim()) - .filter(|s| !s.is_empty()); - - let lookup_label = config_backend.unwrap_or_else(|| job.agent_type.trim()); + let lookup_label = job.agent_type.trim(); if lookup_label.is_empty() { return; } @@ -1036,12 +1013,6 @@ async fn inject_agent_identity( } return; } - - // No catalog hit — fall through to the legacy raw-label emission - // so existing rows keep working. - if let Some(backend) = config_backend { - extra.insert("backend".to_owned(), serde_json::Value::String(backend.to_owned())); - } } #[cfg(test)] @@ -1202,16 +1173,11 @@ fn schedule_description_text(schedule: &crate::types::CronSchedule) -> String { fn default_temp_workspace_path( data_dir: &std::path::Path, agent_type: &AgentType, - job: &CronJob, + _job: &CronJob, conversation_id: &str, ) -> std::path::PathBuf { let label = if *agent_type == AgentType::Acp { - job.agent_config - .as_ref() - .map(|config| config.backend.trim()) - .filter(|backend| !backend.is_empty()) - .unwrap_or("acp") - .to_owned() + "acp".to_owned() } else { agent_type.serde_name().to_owned() }; @@ -1287,7 +1253,6 @@ mod tests { message: "do something".into(), execution_mode: ExecutionMode::Existing, agent_config: Some(CronAgentConfig { - backend: "acp".into(), name: "Claude".into(), cli_path: Some("/usr/bin/claude".into()), is_preset: None, @@ -1296,6 +1261,7 @@ mod tests { preset_agent_type: None, mode: None, model_id: Some("claude-sonnet-4".into()), + model: None, config_options: None, workspace: Some(ensure_named_workspace_path("aionui-cron-sample-job-workspace")), }), @@ -1574,7 +1540,6 @@ mod tests { let job = CronJob { agent_type: "aionrs".into(), agent_config: Some(CronAgentConfig { - backend: "4056cdea".into(), name: "OpenAI".into(), cli_path: None, is_preset: None, @@ -1583,6 +1548,11 @@ mod tests { preset_agent_type: None, mode: None, model_id: Some("gpt-5".into()), + model: Some(ProviderWithModel { + provider_id: "4056cdea".into(), + model: "gpt-5".into(), + use_model: None, + }), config_options: None, workspace: None, }), @@ -1594,11 +1564,10 @@ mod tests { } #[test] - fn resolve_model_aionrs_without_model_id_defaults_to_default() { + fn resolve_model_aionrs_uses_model_payload() { let job = CronJob { agent_type: "aionrs".into(), agent_config: Some(CronAgentConfig { - backend: "4056cdea".into(), name: "OpenAI".into(), cli_path: None, is_preset: None, @@ -1607,14 +1576,19 @@ mod tests { preset_agent_type: None, mode: None, model_id: None, + model: Some(ProviderWithModel { + provider_id: "4056cdea".into(), + model: "gpt-5".into(), + use_model: Some("gpt-5".into()), + }), config_options: None, workspace: None, }), ..sample_job() }; - let model = resolve_model(&job).expect("aionrs without model_id still returns Some"); + let model = resolve_model(&job).expect("aionrs model payload returns Some"); assert_eq!(model.provider_id, "4056cdea"); - assert_eq!(model.model, "default"); + assert_eq!(model.model, "gpt-5"); } #[test] @@ -1630,11 +1604,10 @@ mod tests { } #[test] - fn resolve_model_aionrs_with_empty_backend_returns_none() { + fn resolve_model_aionrs_without_model_returns_none() { let job = CronJob { agent_type: "aionrs".into(), agent_config: Some(CronAgentConfig { - backend: " ".into(), name: "Bogus".into(), cli_path: None, is_preset: None, @@ -1643,6 +1616,7 @@ mod tests { preset_agent_type: None, mode: None, model_id: Some("gpt-5".into()), + model: None, config_options: None, workspace: None, }), @@ -1666,7 +1640,6 @@ mod tests { let registry = hydrated_registry().await; let job = sample_job(); let extra = build_task_extra(®istry, &job, &["cron-cron_test1".into()]).await; - assert_eq!(extra["backend"], "acp"); assert_eq!(extra["cli_path"], "/usr/bin/claude"); assert_eq!(extra["agent_name"], "Claude"); assert_eq!(extra["assistant_id"], "assistant-sample"); diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index 34f7f611e..d06b96aa3 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -7,7 +7,8 @@ use aionui_api_types::{ SaveCronSkillRequest, UpdateCronJobRequest, }; use aionui_common::{ - AgentType, WorkspacePathValidationError, generate_prefixed_id, now_ms, validate_workspace_path_availability, + AgentType, ProviderWithModel, WorkspacePathValidationError, generate_prefixed_id, now_ms, + validate_workspace_path_availability, }; use aionui_db::{ IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, ICronRepository, @@ -38,8 +39,6 @@ const PLACEHOLDER_PATTERNS: &[&str] = &[ "write your", "put your", ]; -const DEPRECATED_AGENT_TYPE_MESSAGE: &str = "This agent type is no longer supported for new conversations."; - #[derive(Clone)] pub struct CronService { repo: Arc, @@ -95,7 +94,6 @@ 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 +159,7 @@ impl CronService { .await? .ok_or_else(|| CronError::JobNotFound(job_id.to_owned()))?; let mut job = cron_job_from_row(existing_row)?; - reject_deprecated_new_conversation_agent_type(&job.agent_type)?; + job.agent_type = self.resolve_job_agent_type(&job).await?; if let Some(name) = &req.name { job.name = name.clone(); @@ -185,6 +183,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?; 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?); } @@ -230,7 +229,9 @@ impl CronService { .get_by_id(job_id) .await? .ok_or_else(|| CronError::JobNotFound(job_id.to_owned()))?; - cron_job_from_row(row) + let mut job = cron_job_from_row(row)?; + job.agent_type = self.resolve_job_agent_type(&job).await?; + Ok(job) } pub async fn list_jobs(&self, query: &ListCronJobsQuery) -> Result, CronError> { @@ -240,7 +241,13 @@ impl CronService { self.repo.list_all().await? }; - rows.into_iter().map(cron_job_from_row).collect() + 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?; + jobs.push(job); + } + Ok(jobs) } // ----------------------------------------------------------------------- @@ -304,13 +311,20 @@ impl CronService { } }; - let job = match cron_job_from_row(row) { + let mut job = match cron_job_from_row(row) { Ok(j) => j, Err(e) => { error!(job_id, error = %e, "Tick: failed to parse job"); return; } }; + match self.resolve_job_agent_type(&job).await { + Ok(agent_type) => job.agent_type = agent_type, + Err(e) => { + error!(job_id, error = %e, "Tick: failed to resolve cron assistant runtime"); + return; + } + } if !job.enabled { info!(job_id, "Tick: job disabled, skipping"); @@ -368,7 +382,8 @@ impl CronService { .get_by_id(job_id) .await? .ok_or_else(|| CronError::JobNotFound(job_id.to_owned()))?; - let job = cron_job_from_row(row)?; + let mut job = cron_job_from_row(row)?; + job.agent_type = self.resolve_job_agent_type(&job).await?; let prepared = self.executor.prepare_run_now(&job).await?; let conversation_id = prepared.conversation_id.clone(); let service = self.clone(); @@ -457,12 +472,35 @@ impl CronService { )); }; + self.resolve_agent_type_for_assistant_id(assistant_id).await + } + + async fn resolve_job_agent_type(&self, job: &CronJob) -> Result { + if !job.agent_type.trim().is_empty() { + return Ok(job.agent_type.clone()); + } + + let Some(assistant_id) = job + .agent_config + .as_ref() + .and_then(|config| config.assistant_id.as_deref()) + .filter(|value| !value.trim().is_empty()) + else { + return Err(CronError::InvalidAgentConfig( + "assistant_id is required for cron jobs".into(), + )); + }; + + self.resolve_agent_type_for_assistant_id(assistant_id).await + } + + async fn resolve_agent_type_for_assistant_id(&self, assistant_id: &str) -> Result { let definition = self .assistant_definition_repo - .get_by_key(assistant_id) + .get_by_assistant_id(assistant_id) .await? .ok_or_else(|| CronError::InvalidAgentConfig(format!("assistant '{assistant_id}' not found")))?; - let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; + let overlay = self.assistant_overlay_repo.get(&definition.id).await?; let effective_agent_id = overlay .as_ref() .and_then(|item| item.agent_id_override.as_deref()) @@ -890,18 +928,47 @@ impl CronService { }; // Both interactive `send_message` and the cron executor parse // `conversation.model` via the same helper. Keeping the cron-side - // `agent_config.backend` derivation in sync with that parser - // prevents the cached vendor-label fallback (`"aionrs"`) from - // sneaking back in (Sentry ELECTRON-1HM). + // `agent_config.model` derivation in sync with that parser prevents + // the cached vendor-label fallback (`"aionrs"`) from sneaking back in + // (Sentry ELECTRON-1HM). let model_resolved = aionui_conversation::task_options::provider_model_from_conversation_row(row); let model = (!model_resolved.provider_id.is_empty()).then_some(&model_resolved); let preset_assistant_id = get_string(&extra, &["preset_assistant_id", "presetAssistantId"]); let extra_assistant_id = get_string(&extra, &["assistant_id", "assistantId"]).or(preset_assistant_id); let snapshot_assistant_id = assistant_snapshot .as_ref() - .map(|snapshot| snapshot.assistant_key.trim().to_owned()) + .map(|snapshot| snapshot.assistant_id.trim().to_owned()) .filter(|value| !value.is_empty()); - let assistant_id = snapshot_assistant_id.or(extra_assistant_id); + let legacy_agent_label = if row.r#type == "aionrs" { + Some("aionrs".to_owned()) + } else { + model + .map(|value| value.provider_id.clone()) + .filter(|value| !value.is_empty()) + .or_else(|| get_string(&extra, &["backend"])) + .or_else(|| Some(row.r#type.clone())) + }; + let legacy_assistant_id = match ( + snapshot_assistant_id.as_ref(), + extra_assistant_id.as_ref(), + legacy_agent_label, + ) { + (None, None, Some(label)) => self.resolve_assistant_id_for_agent_label(&label).await, + _ => None, + }; + let fallback_assistant_id = match ( + snapshot_assistant_id.as_ref(), + extra_assistant_id.as_ref(), + legacy_assistant_id.as_ref(), + ) { + (None, None, None) => self.resolve_default_assistant_id().await, + _ => None, + }; + let uses_default_assistant_fallback = fallback_assistant_id.is_some(); + let assistant_id = snapshot_assistant_id + .or(extra_assistant_id) + .or(legacy_assistant_id) + .or(fallback_assistant_id); let snapshot_backend = match assistant_snapshot.as_ref() { Some(snapshot) => match self.runtime_backend_for_agent_id(snapshot.agent_id.trim()).await { Ok(value) => Some(value).filter(|value| !value.is_empty()), @@ -916,10 +983,14 @@ impl CronService { }, None => None, }; - let assistant_backend = snapshot_backend.clone().or(self - .resolve_assistant_backend(assistant_id.as_deref()) - .await - .unwrap_or(None)); + let assistant_backend = if uses_default_assistant_fallback { + None + } else { + snapshot_backend.clone().or(self + .resolve_assistant_backend(assistant_id.as_deref()) + .await + .unwrap_or(None)) + }; let backend = if row.r#type == "aionrs" { model @@ -946,7 +1017,6 @@ impl CronService { .full_auto_mode_id(Some(backend.as_str())) .to_owned(); let agent_config = aionui_api_types::CronAgentConfigWriteDto { - backend: Some(backend), name: assistant_snapshot .as_ref() .map(|snapshot| snapshot.assistant_name.trim().to_owned()) @@ -976,6 +1046,7 @@ impl CronService { .as_ref() .and_then(|snapshot| snapshot.resolved_model_id.clone()) }), + model: (row.r#type == "aionrs").then(|| model.cloned()).flatten(), config_options: None, workspace: get_string(&extra, &["workspace"]), }; @@ -987,41 +1058,23 @@ impl CronService { &self, runtime_agent_type: &str, config: aionui_api_types::CronAgentConfigWriteDto, - assistant_backend_override: Option<&str>, + _assistant_backend_override: Option<&str>, ) -> Result { - let backend = if runtime_agent_type == "aionrs" { - config - .backend - .clone() - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| { - CronError::InvalidAgentConfig("aionrs cron jobs require agent_config.backend (provider_id)".into()) - })? - } else if let Some(assistant_id) = config.assistant_id.as_deref() { - assistant_backend_override - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .or(self.resolve_assistant_backend(Some(assistant_id)).await?) - .ok_or_else(|| { - CronError::InvalidAgentConfig(format!( - "assistant '{assistant_id}' could not resolve a runtime backend" - )) - })? - } else { - config - .backend - .clone() - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| { - CronError::InvalidAgentConfig( - "agent_config.backend is required when assistant_id is missing".into(), - ) - })? + let Some(assistant_id) = config.assistant_id.as_deref().filter(|value| !value.trim().is_empty()) else { + return Err(CronError::InvalidAgentConfig( + "assistant_id is required for cron jobs".into(), + )); }; + self.resolve_assistant_backend(Some(assistant_id)) + .await? + .ok_or_else(|| { + CronError::InvalidAgentConfig(format!( + "assistant '{assistant_id}' could not resolve a runtime backend" + )) + })?; + Ok(CronAgentConfig { - backend, name: config.name, cli_path: config.cli_path, is_preset: None, @@ -1030,6 +1083,7 @@ impl CronService { preset_agent_type: None, mode: config.mode, model_id: config.model_id, + model: normalize_model(config.model, runtime_agent_type)?, config_options: config.config_options, workspace: config.workspace, }) @@ -1040,10 +1094,10 @@ impl CronService { return Ok(None); }; - let Some(definition) = self.assistant_definition_repo.get_by_key(assistant_id).await? else { + let Some(definition) = self.assistant_definition_repo.get_by_assistant_id(assistant_id).await? else { return Ok(None); }; - let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; + let overlay = self.assistant_overlay_repo.get(&definition.id).await?; let effective_agent_id = overlay .as_ref() .and_then(|item| item.agent_id_override.as_deref()) @@ -1052,6 +1106,46 @@ impl CronService { Ok(Some(self.runtime_backend_for_agent_id(effective_agent_id).await?)) } + async fn resolve_assistant_id_for_agent_label(&self, agent_label: &str) -> Option { + let rows = self.agent_metadata_repo.list_all().await.ok()?; + let binding = resolve_agent_binding_from_rows(&rows, agent_label)?; + self.assistant_definition_repo + .list() + .await + .ok()? + .into_iter() + .filter(|definition| definition.deleted_at.is_none() && definition.agent_id == binding.agent_id) + .min_by_key(|definition| { + let source_rank = match definition.source.as_str() { + "builtin" => 0, + "generated" => 1, + "user" => 2, + _ => 3, + }; + (source_rank, definition.name.clone()) + }) + .map(|definition| definition.assistant_id) + } + + async fn resolve_default_assistant_id(&self) -> Option { + self.assistant_definition_repo + .list() + .await + .ok()? + .into_iter() + .filter(|definition| definition.deleted_at.is_none()) + .min_by_key(|definition| { + let source_rank = match definition.source.as_str() { + "builtin" => 0, + "generated" => 1, + "user" => 2, + _ => 3, + }; + (source_rank, definition.name.clone()) + }) + .map(|definition| definition.assistant_id) + } + async fn runtime_backend_for_agent_id(&self, agent_id: &str) -> Result { let rows = self.agent_metadata_repo.list_all().await?; Ok(resolve_agent_binding_from_rows(&rows, agent_id) @@ -1274,13 +1368,38 @@ fn get_string(extra: &serde_json::Value, keys: &[&str]) -> Option { // Free functions // --------------------------------------------------------------------------- -/// Aionrs cron jobs require `agent_config.backend` (provider_id) to be set — -/// the executor uses it to look up the provider row and build the agent. -/// Reject add/update requests that would produce an invalid aionrs job. fn runtime_agent_type_for_backend(backend: &str) -> &'static str { if backend == "aionrs" { "aionrs" } else { "acp" } } +fn normalize_model( + model: Option, + runtime_agent_type: &str, +) -> Result, CronError> { + let Some(mut model) = model else { + return Ok(None); + }; + + model.provider_id = model.provider_id.trim().to_owned(); + model.model = model.model.trim().to_owned(); + model.use_model = model + .use_model + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()); + + if runtime_agent_type == "aionrs" && (model.provider_id.is_empty() || model.model.is_empty()) { + return Err(CronError::InvalidAgentConfig( + "aionrs cron jobs require agent_config.model.provider_id and agent_config.model.model".into(), + )); + } + + if model.provider_id.is_empty() || model.model.is_empty() { + return Ok(None); + } + + Ok(Some(model)) +} + fn validate_aionrs_agent_config( agent_type: &str, agent_config: Option<&aionui_api_types::CronAgentConfigWriteDto>, @@ -1288,23 +1407,19 @@ fn validate_aionrs_agent_config( if agent_type != "aionrs" { return Ok(()); } - let backend_ok = agent_config.is_some_and(|c| c.backend.as_ref().is_some_and(|value| !value.trim().is_empty())); - if !backend_ok { + let model_ok = agent_config.is_some_and(|c| { + c.model + .as_ref() + .is_some_and(|value| !value.provider_id.trim().is_empty() && !value.model.trim().is_empty()) + }); + if !model_ok { return Err(CronError::InvalidAgentConfig( - "aionrs cron jobs require agent_config.backend (provider_id)".into(), + "aionrs cron jobs require agent_config.model.provider_id and agent_config.model.model".into(), )); } 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.is_deprecated_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), @@ -1388,7 +1503,6 @@ fn build_update_params(job: &CronJob, req: &UpdateCronJobRequest) -> UpdateCronJ agent_config, conversation_id: None, conversation_title: req.conversation_title.as_ref().map(|t| Some(t.clone())), - agent_type: None, skill_content: None, description: req.description.as_ref().map(|value| Some(value.clone())), next_run_at: if req.schedule.is_some() || req.enabled.is_some() { @@ -1407,14 +1521,6 @@ fn build_update_params(job: &CronJob, req: &UpdateCronJobRequest) -> UpdateCronJ fn sanitize_agent_config_dto( mut config: aionui_api_types::CronAgentConfigWriteDto, ) -> aionui_api_types::CronAgentConfigWriteDto { - if let Some(value) = config.backend.as_mut() { - let trimmed = value.trim().to_owned(); - if trimmed.is_empty() { - config.backend = None; - } else { - *value = trimmed; - } - } if let Some(value) = config.assistant_id.as_mut() { let trimmed = value.trim().to_owned(); if trimmed.is_empty() { @@ -1504,14 +1610,18 @@ mod tests { // -- validate_aionrs_agent_config ---------------------------------------- - fn agent_cfg_dto(backend: &str) -> aionui_api_types::CronAgentConfigWriteDto { + fn agent_cfg_dto(provider_id: &str) -> aionui_api_types::CronAgentConfigWriteDto { aionui_api_types::CronAgentConfigWriteDto { - backend: Some(backend.to_owned()), name: "provider".into(), cli_path: None, - assistant_id: None, + assistant_id: Some("assistant-1".into()), mode: None, model_id: Some("gpt-4o".into()), + model: Some(ProviderWithModel { + provider_id: provider_id.to_owned(), + model: "gpt-4o".into(), + use_model: None, + }), config_options: None, workspace: None, } @@ -1530,14 +1640,14 @@ mod tests { } #[test] - fn validate_aionrs_rejects_empty_backend() { + fn validate_aionrs_rejects_empty_provider_id() { let cfg = agent_cfg_dto(""); let err = validate_aionrs_agent_config("aionrs", Some(&cfg)).unwrap_err(); assert!(matches!(err, CronError::InvalidAgentConfig(_))); } #[test] - fn validate_aionrs_rejects_whitespace_backend() { + fn validate_aionrs_rejects_whitespace_provider_id() { let cfg = agent_cfg_dto(" "); let err = validate_aionrs_agent_config("aionrs", Some(&cfg)).unwrap_err(); assert!(matches!(err, CronError::InvalidAgentConfig(_))); @@ -1545,7 +1655,7 @@ mod tests { #[test] fn validate_aionrs_ignores_non_aionrs_type() { - // ACP / other types may legitimately omit agent_config or leave backend empty. + // ACP / other types may legitimately omit agent_config or leave model empty. assert!(validate_aionrs_agent_config("acp", None).is_ok()); let cfg = agent_cfg_dto(""); assert!(validate_aionrs_agent_config("claude", Some(&cfg)).is_ok()); @@ -1554,12 +1664,12 @@ mod tests { #[test] fn sanitize_agent_config_dto_clears_legacy_ids_when_assistant_id_present() { let config = aionui_api_types::CronAgentConfigWriteDto { - backend: Some("claude".into()), name: "Helper".into(), cli_path: None, assistant_id: Some("assistant-1".into()), mode: Some("default".into()), model_id: Some("claude-sonnet-4".into()), + model: None, config_options: None, workspace: None, }; @@ -1572,7 +1682,6 @@ mod tests { #[test] fn sanitize_agent_config_dto_rejects_legacy_custom_agent_id_without_assistant_id() { let err = serde_json::from_value::(serde_json::json!({ - "backend": "claude", "name": "Helper", "custom_agent_id": "legacy-assistant", "preset_agent_type": "claude", @@ -1699,7 +1808,6 @@ mod tests { fn build_update_params_strips_legacy_ids_when_assistant_id_present() { let mut job = sample_job(); job.agent_config = Some(CronAgentConfig { - backend: "claude".into(), name: "Helper".into(), cli_path: None, is_preset: None, @@ -1708,6 +1816,7 @@ mod tests { preset_agent_type: None, mode: Some("default".into()), model_id: Some("claude-sonnet-4".into()), + model: None, config_options: None, workspace: None, }); @@ -1719,12 +1828,12 @@ mod tests { message: None, execution_mode: None, agent_config: Some(aionui_api_types::CronAgentConfigWriteDto { - backend: Some("claude".into()), name: "Helper".into(), cli_path: None, assistant_id: Some("assistant-1".into()), mode: Some("default".into()), model_id: Some("claude-sonnet-4".into()), + model: None, config_options: None, workspace: None, }), @@ -1745,7 +1854,6 @@ mod tests { #[test] fn build_update_params_rejects_legacy_custom_agent_id_without_assistant_id() { let err = serde_json::from_value::(serde_json::json!({ - "backend": "claude", "name": "Helper", "custom_agent_id": "legacy-assistant", "preset_agent_type": "claude", diff --git a/crates/aionui-cron/src/types.rs b/crates/aionui-cron/src/types.rs index 06e2cbec5..abadaf03f 100644 --- a/crates/aionui-cron/src/types.rs +++ b/crates/aionui-cron/src/types.rs @@ -5,7 +5,7 @@ use aionui_api_types::{ CronAgentConfigReadDto, CronJobMetadataDto, CronJobPayloadDto, CronJobResponse, CronJobStateDto, CronJobTargetDto, CronScheduleDto, }; -use aionui_common::TimestampMs; +use aionui_common::{ProviderWithModel, TimestampMs}; use aionui_db::models::CronJobRow; use serde::{Deserialize, Serialize}; @@ -125,7 +125,6 @@ impl FromStr for JobStatus { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CronAgentConfig { - pub backend: String, pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub cli_path: Option, @@ -142,6 +141,8 @@ pub struct CronAgentConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub config_options: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace: Option, @@ -210,7 +211,7 @@ pub fn cron_job_from_row(row: CronJobRow) -> Result { agent_config, conversation_id: row.conversation_id, conversation_title: row.conversation_title, - agent_type: row.agent_type, + agent_type: String::new(), created_by, skill_content: row.skill_content, description: row.description, @@ -282,7 +283,6 @@ pub fn cron_job_to_row(job: &CronJob) -> Result { agent_config: agent_config_json, conversation_id: job.conversation_id.clone(), conversation_title: job.conversation_title.clone(), - agent_type: job.agent_type.clone(), created_by: job.created_by.as_str().to_owned(), skill_content: job.skill_content.clone(), description: job.description.clone(), @@ -334,9 +334,7 @@ pub fn cron_job_to_response(job: &CronJob) -> CronJobResponse { let agent_config_dto = job.agent_config.as_ref().map(|c| { let canonical_assistant_id = c.assistant_id.clone().or_else(|| c.custom_agent_id.clone()); let assistant_backed = canonical_assistant_id.is_some(); - let preserve_backend = !assistant_backed || job.agent_type == "aionrs"; CronAgentConfigReadDto { - backend: preserve_backend.then(|| c.backend.clone()), name: c.name.clone(), cli_path: if assistant_backed { None } else { c.cli_path.clone() }, is_preset: if assistant_backed { None } else { c.is_preset }, @@ -353,6 +351,7 @@ pub fn cron_job_to_response(job: &CronJob) -> CronJobResponse { }, mode: c.mode.clone(), model_id: c.model_id.clone(), + model: c.model.clone(), config_options: c.config_options.clone(), workspace: c.workspace.clone(), } @@ -571,7 +570,6 @@ mod tests { agent_config: Some(r#"{"backend":"acp","name":"Claude"}"#.into()), conversation_id: "conv_1".into(), conversation_title: Some("Test Conv".into()), - agent_type: "acp".into(), created_by: "user".into(), skill_content: Some("---\nname: test\n---\nContent".into()), description: None, @@ -599,7 +597,6 @@ mod tests { message: "do something".into(), execution_mode: ExecutionMode::Existing, agent_config: Some(CronAgentConfig { - backend: "acp".into(), name: "Claude".into(), cli_path: None, is_preset: None, @@ -608,6 +605,7 @@ mod tests { preset_agent_type: None, mode: None, model_id: None, + model: None, config_options: None, workspace: None, }), @@ -647,7 +645,7 @@ mod tests { assert_eq!(job.created_by, CreatedBy::User); assert_eq!(job.last_status, Some(JobStatus::Ok)); assert!(job.agent_config.is_some()); - assert_eq!(job.agent_config.as_ref().unwrap().backend, "acp"); + assert_eq!(job.agent_config.as_ref().unwrap().name, "Claude"); } #[test] @@ -832,7 +830,6 @@ mod tests { fn domain_to_dto_strips_legacy_agent_fields_for_assistant_backed_jobs() { let job = CronJob { agent_config: Some(CronAgentConfig { - backend: "codex".into(), name: "文件规划助手".into(), cli_path: Some("/tmp/codex".into()), is_preset: Some(true), @@ -841,6 +838,7 @@ mod tests { preset_agent_type: Some("codex".into()), mode: Some("full-access".into()), model_id: Some("gpt-5-codex".into()), + model: None, config_options: Some(HashMap::from([("sandbox_mode".into(), "workspace-write".into())])), workspace: Some("/tmp/project".into()), }), @@ -851,7 +849,6 @@ mod tests { let config = resp.metadata.agent_config.expect("assistant config should be present"); assert_eq!(config.assistant_id.as_deref(), Some("assistant-1")); - assert!(config.backend.is_none()); assert!(config.cli_path.is_none()); assert!(config.is_preset.is_none()); assert!(config.custom_agent_id.is_none()); @@ -882,7 +879,6 @@ mod tests { fn domain_to_dto_promotes_legacy_custom_agent_id_to_assistant_id() { let job = CronJob { agent_config: Some(CronAgentConfig { - backend: "claude".into(), name: "Legacy Assistant Job".into(), cli_path: Some("/tmp/claude".into()), is_preset: Some(false), @@ -891,6 +887,7 @@ mod tests { preset_agent_type: None, mode: Some("default".into()), model_id: Some("claude-sonnet-4".into()), + model: None, config_options: None, workspace: Some("/tmp/project".into()), }), @@ -901,17 +898,15 @@ mod tests { let config = resp.metadata.agent_config.expect("assistant config should be present"); assert_eq!(config.assistant_id.as_deref(), Some("legacy-assistant")); - assert!(config.backend.is_none()); assert!(config.custom_agent_id.is_none()); assert!(config.cli_path.is_none()); } #[test] - fn domain_to_dto_keeps_provider_backend_for_aionrs_assistant_jobs() { + fn domain_to_dto_keeps_model_for_aionrs_assistant_jobs() { let job = CronJob { agent_type: "aionrs".into(), agent_config: Some(CronAgentConfig { - backend: "gemini".into(), name: "Gemini Bare Assistant".into(), cli_path: None, is_preset: None, @@ -920,6 +915,11 @@ mod tests { preset_agent_type: None, mode: Some("default".into()), model_id: Some("gemini-2.5-pro".into()), + model: Some(ProviderWithModel { + provider_id: "gemini".into(), + model: "gemini-2.5-pro".into(), + use_model: None, + }), config_options: None, workspace: Some("/tmp/project".into()), }), @@ -930,7 +930,10 @@ mod tests { let config = resp.metadata.agent_config.expect("assistant config should be present"); assert_eq!(config.assistant_id.as_deref(), Some("bare-gemini")); - assert_eq!(config.backend.as_deref(), Some("gemini")); + assert_eq!( + config.model.as_ref().map(|model| model.provider_id.as_str()), + Some("gemini") + ); } // -- DTO → Domain schedule ------------------------------------------------ diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index abea1c5df..1109da03b 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -17,7 +17,7 @@ use aionui_api_types::{ CreateCronJobRequest, CronScheduleDto, ListCronJobsQuery, SaveCronSkillRequest, UpdateCronJobRequest, WebSocketMessage, }; -use aionui_common::{PaginatedResult, TimestampMs, now_ms}; +use aionui_common::{PaginatedResult, ProviderWithModel, TimestampMs, now_ms}; use aionui_conversation::ConversationService; use aionui_conversation::response_middleware::{CronCreateParams, CronUpdateParams}; use aionui_db::{ @@ -749,6 +749,7 @@ async fn setup_with_conv_repo() -> ( "claude", ) .await; + seed_bare_assistant_definitions(&assistant_definition_repo).await; std::mem::forget(db); (svc, cron_repo, bc, stub_conv_repo) @@ -844,6 +845,7 @@ async fn setup_with_assistant_repos() -> ( "claude", ) .await; + seed_bare_assistant_definitions(&assistant_definition_repo).await; std::mem::forget(db); ( @@ -868,12 +870,12 @@ fn make_create_req(name: &str, schedule: CronScheduleDto) -> CreateCronJobReques created_by: "user".into(), execution_mode: None, agent_config: Some(aionui_api_types::CronAgentConfigWriteDto { - backend: Some("claude".into()), name: "Default Assistant".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, }), @@ -883,19 +885,19 @@ fn make_create_req(name: &str, schedule: CronScheduleDto) -> CreateCronJobReques async fn seed_assistant_definition( repo: &Arc, definition_id: &str, - assistant_key: &str, + assistant_id: &str, agent_backend: &str, ) { let agent_id = seeded_agent_id(agent_backend); repo.upsert(&UpsertAssistantDefinitionParams { - definition_id, - assistant_key, + id: definition_id, + assistant_id, source: "user", owner_type: "user", - source_ref: Some(assistant_key), + source_ref: Some(assistant_id), source_version: None, source_hash: None, - name: assistant_key, + name: assistant_id, name_i18n: "{}", description: Some("test assistant"), description_i18n: "{}", @@ -922,6 +924,16 @@ async fn seed_assistant_definition( .unwrap(); } +async fn seed_bare_assistant_definitions(repo: &Arc) { + for (definition_id, assistant_id, agent_backend) in [ + ("asstdef_bare_gemini", "bare:cc126dd5", "gemini"), + ("asstdef_bare_codex", "bare:8e1acf31", "codex"), + ("asstdef_bare_aionrs", "bare:632f31d2", "aionrs"), + ] { + seed_assistant_definition(repo, definition_id, assistant_id, agent_backend).await; + } +} + async fn seed_assistant_overlay( repo: &Arc, definition_id: &str, @@ -929,7 +941,7 @@ async fn seed_assistant_overlay( ) { let agent_id_override = agent_backend_override.map(seeded_agent_id); repo.upsert(&UpsertAssistantOverlayParams { - definition_id, + assistant_definition_id: definition_id, enabled: true, sort_order: 0, agent_id_override, @@ -997,12 +1009,12 @@ async fn create_job_strips_legacy_agent_ids_when_assistant_id_present() { seed_assistant_definition(&definition_repo, "asstdef_assistant_1", "assistant-1", "claude").await; let mut req = make_create_req("Assistant Only Create", every_60s()); req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { - backend: Some("claude".into()), name: "Helper".into(), cli_path: None, assistant_id: Some("assistant-1".into()), mode: Some("default".into()), model_id: Some("claude-sonnet-4".into()), + model: None, config_options: None, workspace: None, }); @@ -1017,26 +1029,24 @@ async fn create_job_strips_legacy_agent_ids_when_assistant_id_present() { } #[tokio::test] -async fn create_job_prefers_assistant_runtime_over_stale_backend_hint() { +async fn create_job_derives_assistant_runtime_without_backend_hint() { let (svc, _, _) = setup().await; let mut req = make_create_req("Stale Backend Hint", every_60s()); req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { - backend: Some("openclaw-gateway".into()), 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(); - let config = job.agent_config.expect("agent config"); assert_eq!(job.agent_type, "acp"); - assert_eq!(config.backend, "claude"); } #[tokio::test] @@ -1057,12 +1067,16 @@ async fn create_job_derives_runtime_type_from_aionrs_assistant() { let mut req = make_create_req("Assistant Aionrs", every_60s()); req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { - backend: Some("provider-gemini".into()), name: "Aionrs Assistant".into(), cli_path: None, assistant_id: Some("assistant-aionrs".into()), mode: Some("yolo".into()), model_id: Some("gemini-3.1-pro-preview".into()), + model: Some(ProviderWithModel { + provider_id: "provider-gemini".into(), + model: "gemini-3.1-pro-preview".into(), + use_model: None, + }), config_options: None, workspace: None, }); @@ -1086,12 +1100,16 @@ async fn create_job_derives_runtime_type_from_assistant_overlay_override() { let mut req = make_create_req("Assistant Override", every_60s()); req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { - backend: Some("provider-openai".into()), name: "Override Assistant".into(), cli_path: None, assistant_id: Some("assistant-override".into()), mode: Some("acceptEdits".into()), model_id: Some("gpt-5.4".into()), + model: Some(ProviderWithModel { + provider_id: "provider-openai".into(), + model: "gpt-5.4".into(), + use_model: None, + }), config_options: None, workspace: None, }); @@ -1108,12 +1126,12 @@ async fn create_job_allows_assistant_backed_acp_jobs_without_backend_hint() { let mut req = make_create_req("Assistant Without Backend", every_60s()); req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { - backend: None, name: "Helper".into(), cli_path: None, assistant_id: Some("assistant-2".into()), mode: Some("default".into()), model_id: Some("claude-sonnet-4".into()), + model: None, config_options: None, workspace: None, }); @@ -1123,7 +1141,6 @@ async fn create_job_allows_assistant_backed_acp_jobs_without_backend_hint() { assert_eq!(job.agent_type, "acp"); assert_eq!(config.assistant_id.as_deref(), Some("assistant-2")); - assert_eq!(config.backend, "claude"); } #[tokio::test] @@ -1132,12 +1149,12 @@ async fn create_job_rejects_backend_fallback_when_assistant_id_cannot_resolve() let mut req = make_create_req("Assistant Missing", every_60s()); req.agent_config = Some(aionui_api_types::CronAgentConfigWriteDto { - backend: Some("claude".into()), name: "Helper".into(), cli_path: None, assistant_id: Some("missing-assistant".into()), mode: Some("default".into()), model_id: Some("claude-sonnet-4".into()), + model: None, config_options: None, workspace: None, }); @@ -1303,12 +1320,12 @@ async fn update_job_strips_legacy_agent_ids_when_assistant_id_present() { message: None, execution_mode: None, agent_config: Some(aionui_api_types::CronAgentConfigWriteDto { - backend: Some("claude".into()), name: "Helper".into(), cli_path: None, assistant_id: Some("assistant-1".into()), mode: Some("default".into()), model_id: Some("claude-sonnet-4".into()), + model: None, config_options: None, workspace: None, }), @@ -1326,7 +1343,7 @@ async fn update_job_strips_legacy_agent_ids_when_assistant_id_present() { } #[tokio::test] -async fn update_job_rejects_backend_fallback_when_assistant_id_cannot_resolve() { +async fn update_job_rejects_when_assistant_id_cannot_resolve() { let (svc, _, _) = setup().await; let created = svc .add_job(make_create_req("Assistant Missing Update", every_60s())) @@ -1341,12 +1358,12 @@ async fn update_job_rejects_backend_fallback_when_assistant_id_cannot_resolve() message: None, execution_mode: None, agent_config: Some(aionui_api_types::CronAgentConfigWriteDto { - backend: Some("claude".into()), name: "Helper".into(), cli_path: None, assistant_id: Some("missing-assistant".into()), mode: Some("default".into()), model_id: Some("claude-sonnet-4".into()), + model: None, config_options: None, workspace: None, }), @@ -1361,8 +1378,7 @@ async fn update_job_rejects_backend_fallback_when_assistant_id_cannot_resolve() assert!(matches!(err, aionui_cron::error::CronError::InvalidAgentConfig(_))); assert!( - err.to_string() - .contains("assistant 'missing-assistant' could not resolve a runtime backend"), + err.to_string().contains("assistant 'missing-assistant' not found"), "unexpected error: {err}" ); } @@ -1824,7 +1840,7 @@ async fn icron_service_create_job() { } #[tokio::test] -async fn icron_service_create_job_inherits_conversation_mode_and_backend() { +async fn icron_service_create_job_inherits_conversation_mode_without_backend() { let (svc, _, _) = setup().await; use aionui_conversation::response_middleware::ICronService; @@ -1851,7 +1867,6 @@ async fn icron_service_create_job_inherits_conversation_mode_and_backend() { let config = job.agent_config.as_ref().expect("agent config should be copied"); assert_eq!(job.agent_type, "acp"); assert_eq!(job.conversation_title.as_deref(), Some("Gemini Chat")); - assert_eq!(config.backend, "gemini"); assert_eq!(config.name, "Gemini"); assert_eq!(config.mode.as_deref(), Some("yolo")); assert_eq!(config.model_id.as_deref(), Some("gemini-2.5-pro")); @@ -1862,7 +1877,7 @@ async fn icron_service_create_job_inherits_conversation_mode_and_backend() { } #[tokio::test] -async fn icron_service_create_job_prefers_model_provider_over_stale_extra_backend() { +async fn icron_service_create_job_ignores_stale_extra_backend() { let (svc, _, _) = setup().await; use aionui_conversation::response_middleware::ICronService; @@ -1888,12 +1903,11 @@ async fn icron_service_create_job_prefers_model_provider_over_stale_extra_backen let job = &jobs[0]; let config = job.agent_config.as_ref().expect("agent config should be copied"); assert_eq!(job.agent_type, "acp"); - assert_eq!(config.backend, "gemini"); assert_eq!(config.model_id.as_deref(), Some("gemini-2.5-pro")); } #[tokio::test] -async fn icron_service_create_job_prefers_assistant_backend_over_stale_extra_backend() { +async fn icron_service_create_job_derives_assistant_runtime_over_stale_extra_backend() { let (svc, _, _, _, definition_repo, overlay_repo) = setup_with_assistant_repos().await; seed_assistant_definition( &definition_repo, @@ -1926,8 +1940,8 @@ async fn icron_service_create_job_prefers_assistant_backend_over_stale_extra_bac let job = &jobs[0]; let config = job.agent_config.as_ref().expect("agent config should be copied"); + assert_eq!(job.agent_type, "aionrs"); assert_eq!(config.assistant_id.as_deref(), Some("assistant-override")); - assert_eq!(config.backend, "aionrs"); assert_eq!(config.mode.as_deref(), Some("yolo")); assert_eq!(config.model_id.as_deref(), Some("gpt-5.4")); } @@ -1939,7 +1953,7 @@ async fn icron_service_create_job_inherits_assistant_snapshot_identity() { conv_repo.insert_assistant_snapshot(ConversationAssistantSnapshotRow { conversation_id: "conv_mode_assistant_snapshot".into(), assistant_definition_id: "asstdef_snapshot".into(), - assistant_key: "assistant-snapshot".into(), + assistant_id: "assistant-snapshot".into(), assistant_source: "bare".into(), assistant_name: "Snapshot Assistant".into(), assistant_avatar_type: "emoji".into(), @@ -1979,9 +1993,10 @@ async fn icron_service_create_job_inherits_assistant_snapshot_identity() { .unwrap(); assert_eq!(jobs.len(), 1); - let config = jobs[0].agent_config.as_ref().expect("agent config should be copied"); + let job = &jobs[0]; + let config = job.agent_config.as_ref().expect("agent config should be copied"); + assert_eq!(job.agent_type, "acp"); assert_eq!(config.assistant_id.as_deref(), Some("assistant-snapshot")); - assert_eq!(config.backend, "codex"); assert_eq!(config.name, "Snapshot Assistant"); assert_eq!(config.model_id.as_deref(), Some("gpt-5.1")); } diff --git a/crates/aionui-db/migrations/013_agent_connection_snapshot.sql b/crates/aionui-db/migrations/013_agent_connection_snapshot.sql index af8305ea4..37c1ce0fa 100644 --- a/crates/aionui-db/migrations/013_agent_connection_snapshot.sql +++ b/crates/aionui-db/migrations/013_agent_connection_snapshot.sql @@ -22,11 +22,24 @@ ALTER TABLE agent_metadata ADD COLUMN env_override TEXT; -- Assistant/agent unification: assistant storage now binds to the concrete -- agent catalog row. Runtime backend labels remain compatibility/runtime -- fields on legacy mirrors, conversation extra, and acp_session. +-- +-- Assistant identity boundary: +-- - `assistant_definitions.id` is the internal definition row id. +-- - `assistant_definitions.assistant_id` is the stable assistant id exposed to +-- callers, conversations, teams, channels, and cron. +-- - foreign keys to the definition row use `assistant_definition_id`. +ALTER TABLE assistant_definitions RENAME COLUMN definition_id TO id; +ALTER TABLE assistant_definitions RENAME COLUMN assistant_key TO assistant_id; +ALTER TABLE assistant_overlays RENAME COLUMN definition_id TO assistant_definition_id; +ALTER TABLE assistant_preferences RENAME COLUMN definition_id TO assistant_definition_id; +ALTER TABLE conversation_assistant_snapshots RENAME COLUMN assistant_key TO assistant_id; + ALTER TABLE assistant_definitions RENAME COLUMN agent_backend TO agent_id; ALTER TABLE assistant_overlays RENAME COLUMN agent_backend_override TO agent_id_override; ALTER TABLE conversation_assistant_snapshots RENAME COLUMN agent_backend TO agent_id; DROP INDEX IF EXISTS idx_assistant_definitions_agent_backend; +DROP INDEX IF EXISTS idx_assistant_definitions_assistant_key; UPDATE assistant_definitions SET agent_id = COALESCE( @@ -181,9 +194,195 @@ SET agent_id = COALESCE( CREATE INDEX IF NOT EXISTS idx_assistant_definitions_agent_id ON assistant_definitions(agent_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_assistant_definitions_assistant_id + ON assistant_definitions(assistant_id); + CREATE INDEX IF NOT EXISTS idx_assistant_overlays_agent_id_override ON assistant_overlays(agent_id_override) WHERE agent_id_override IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_conversation_assistant_snapshots_agent_id ON conversation_assistant_snapshots(agent_id); + +-- Cron assistant-first cleanup: +-- - `cron_jobs.agent_type` is derived from assistant identity at runtime. +-- - `agent_config.backend` was overloaded. For aionrs rows it held the LLM +-- provider_id, so migrate it into `agent_config.model.provider_id`. +-- Runtime backend is no longer persisted in cron config. +UPDATE cron_jobs +SET agent_config = json_remove( + CASE + WHEN agent_type = 'aionrs' + AND json_extract(agent_config, '$.backend') IS NOT NULL + AND TRIM(json_extract(agent_config, '$.backend')) != '' + THEN json_set( + agent_config, + '$.model', + json_object( + 'provider_id', json_extract(agent_config, '$.backend'), + 'model', COALESCE(NULLIF(TRIM(json_extract(agent_config, '$.model_id')), ''), 'default'), + 'use_model', json_extract(agent_config, '$.model_id') + ) + ) + ELSE agent_config + END, + '$.backend', + '$.custom_agent_id', + '$.preset_agent_type', + '$.is_preset', + '$.cli_path' +) +WHERE agent_config IS NOT NULL + AND json_valid(agent_config); + +UPDATE cron_jobs +SET agent_config = json_set( + COALESCE(agent_config, '{}'), + '$.assistant_id', + COALESCE( + NULLIF(TRIM(json_extract(agent_config, '$.assistant_id')), ''), + ( + SELECT cas.assistant_id + FROM conversation_assistant_snapshots cas + WHERE cas.conversation_id = cron_jobs.conversation_id + LIMIT 1 + ), + ( + SELECT ad.assistant_id + FROM assistant_definitions ad + WHERE ad.deleted_at IS NULL + AND ad.agent_id = COALESCE( + ( + SELECT am.id + FROM agent_metadata am + WHERE am.id = cron_jobs.agent_type + OR am.backend = cron_jobs.agent_type + OR am.agent_type = cron_jobs.agent_type + ORDER BY + CASE am.agent_source + WHEN 'builtin' THEN 0 + WHEN 'internal' THEN 1 + ELSE 2 + END, + am.sort_order ASC, + am.name ASC + LIMIT 1 + ), + cron_jobs.agent_type + ) + ORDER BY + CASE ad.source + WHEN 'builtin' THEN 0 + WHEN 'generated' THEN 1 + ELSE 2 + END, + ad.name ASC + LIMIT 1 + ) + ) +) +WHERE (agent_config IS NULL OR json_valid(agent_config)) + AND COALESCE(NULLIF(TRIM(json_extract(agent_config, '$.assistant_id')), ''), '') = '' + AND COALESCE( + ( + SELECT cas.assistant_id + FROM conversation_assistant_snapshots cas + WHERE cas.conversation_id = cron_jobs.conversation_id + LIMIT 1 + ), + ( + SELECT ad.assistant_id + FROM assistant_definitions ad + WHERE ad.deleted_at IS NULL + AND ad.agent_id = COALESCE( + ( + SELECT am.id + FROM agent_metadata am + WHERE am.id = cron_jobs.agent_type + OR am.backend = cron_jobs.agent_type + OR am.agent_type = cron_jobs.agent_type + ORDER BY + CASE am.agent_source + WHEN 'builtin' THEN 0 + WHEN 'internal' THEN 1 + ELSE 2 + END, + am.sort_order ASC, + am.name ASC + LIMIT 1 + ), + cron_jobs.agent_type + ) + ORDER BY + CASE ad.source + WHEN 'builtin' THEN 0 + WHEN 'generated' THEN 1 + ELSE 2 + END, + ad.name ASC + LIMIT 1 + ) + ) IS NOT NULL; + +UPDATE cron_jobs +SET enabled = 0, + last_status = 'error', + last_error = 'invalid agent_config JSON during cron assistant-first migration' +WHERE agent_config IS NOT NULL + AND NOT json_valid(agent_config); + +UPDATE cron_jobs +SET enabled = 0, + last_status = 'error', + last_error = 'assistant_id could not be recovered during cron assistant-first migration' +WHERE (agent_config IS NULL OR json_valid(agent_config)) + AND COALESCE(NULLIF(TRIM(json_extract(agent_config, '$.assistant_id')), ''), '') = ''; + +DROP INDEX IF EXISTS idx_cron_jobs_agent_type; + +CREATE TABLE IF NOT EXISTS _cron_jobs_new ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + schedule_kind TEXT NOT NULL CHECK(schedule_kind IN ('at', 'every', 'cron')), + schedule_value TEXT NOT NULL, + schedule_tz TEXT, + schedule_description TEXT, + payload_message TEXT NOT NULL, + execution_mode TEXT NOT NULL DEFAULT 'existing' + CHECK(execution_mode IN ('existing', 'new_conversation')), + agent_config TEXT, + conversation_id TEXT NOT NULL, + conversation_title TEXT, + created_by TEXT NOT NULL CHECK(created_by IN ('user', 'agent')), + skill_content TEXT, + description TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + next_run_at INTEGER, + last_run_at INTEGER, + last_status TEXT CHECK(last_status IN ('ok', 'error', 'skipped', 'missed')), + last_error TEXT, + run_count INTEGER NOT NULL DEFAULT 0, + retry_count INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 3 +); + +INSERT OR IGNORE INTO _cron_jobs_new + (id, name, enabled, schedule_kind, schedule_value, schedule_tz, schedule_description, + payload_message, execution_mode, agent_config, conversation_id, conversation_title, + created_by, skill_content, description, created_at, updated_at, + next_run_at, last_run_at, last_status, last_error, run_count, retry_count, max_retries) +SELECT + id, name, enabled, schedule_kind, schedule_value, schedule_tz, schedule_description, + payload_message, execution_mode, agent_config, conversation_id, conversation_title, + created_by, skill_content, description, created_at, updated_at, + next_run_at, last_run_at, last_status, last_error, run_count, retry_count, max_retries +FROM cron_jobs; + +ALTER TABLE cron_jobs RENAME TO _cron_jobs_old; +ALTER TABLE _cron_jobs_new RENAME TO cron_jobs; +DROP TABLE IF EXISTS _cron_jobs_old; + +CREATE INDEX IF NOT EXISTS idx_cron_jobs_conversation ON cron_jobs(conversation_id); +CREATE INDEX IF NOT EXISTS idx_cron_jobs_next_run ON cron_jobs(next_run_at) WHERE enabled = 1; diff --git a/crates/aionui-db/src/models/assistant.rs b/crates/aionui-db/src/models/assistant.rs index 81c7dac84..8e932a014 100644 --- a/crates/aionui-db/src/models/assistant.rs +++ b/crates/aionui-db/src/models/assistant.rs @@ -45,8 +45,8 @@ pub struct AssistantOverrideRow { /// Row mapping for the `assistant_definitions` table. #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct AssistantDefinitionRow { - pub definition_id: String, - pub assistant_key: String, + pub id: String, + pub assistant_id: String, pub source: String, pub owner_type: String, pub source_ref: Option, @@ -82,7 +82,7 @@ pub struct AssistantDefinitionRow { /// Row mapping for the `assistant_overlays` table. #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct AssistantOverlayRow { - pub definition_id: String, + pub assistant_definition_id: String, pub enabled: bool, pub sort_order: i32, pub agent_id_override: Option, @@ -94,7 +94,7 @@ pub struct AssistantOverlayRow { /// Row mapping for the `assistant_preferences` table. #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct AssistantPreferenceRow { - pub definition_id: String, + pub assistant_definition_id: String, pub last_model_id: Option, pub last_permission_value: Option, pub last_skill_ids: String, @@ -161,8 +161,8 @@ pub struct UpsertOverrideParams<'a> { /// Insert-or-update parameters for `assistant_definitions`. #[derive(Debug, Clone)] pub struct UpsertAssistantDefinitionParams<'a> { - pub definition_id: &'a str, - pub assistant_key: &'a str, + pub id: &'a str, + pub assistant_id: &'a str, pub source: &'a str, pub owner_type: &'a str, pub source_ref: Option<&'a str>, @@ -195,7 +195,7 @@ pub struct UpsertAssistantDefinitionParams<'a> { /// Insert-or-update parameters for `assistant_overlays`. #[derive(Debug, Clone)] pub struct UpsertAssistantOverlayParams<'a> { - pub definition_id: &'a str, + pub assistant_definition_id: &'a str, pub enabled: bool, pub sort_order: i32, pub agent_id_override: Option<&'a str>, @@ -205,7 +205,7 @@ pub struct UpsertAssistantOverlayParams<'a> { /// Insert-or-update parameters for `assistant_preferences`. #[derive(Debug, Clone)] pub struct UpsertAssistantPreferenceParams<'a> { - pub definition_id: &'a str, + pub assistant_definition_id: &'a str, pub last_model_id: Option<&'a str>, pub last_permission_value: Option<&'a str>, pub last_skill_ids: &'a str, diff --git a/crates/aionui-db/src/models/conversation.rs b/crates/aionui-db/src/models/conversation.rs index 80317e623..514496ca6 100644 --- a/crates/aionui-db/src/models/conversation.rs +++ b/crates/aionui-db/src/models/conversation.rs @@ -39,7 +39,7 @@ pub struct ConversationRow { pub struct ConversationAssistantSnapshotRow { pub conversation_id: String, pub assistant_definition_id: String, - pub assistant_key: String, + pub assistant_id: String, pub assistant_source: String, pub assistant_name: String, pub assistant_avatar_type: String, @@ -64,7 +64,7 @@ pub struct ConversationAssistantSnapshotRow { pub struct UpsertConversationAssistantSnapshotParams<'a> { pub conversation_id: &'a str, pub assistant_definition_id: &'a str, - pub assistant_key: &'a str, + pub assistant_id: &'a str, pub assistant_source: &'a str, pub assistant_name: &'a str, pub assistant_avatar_type: &'a str, diff --git a/crates/aionui-db/src/models/cron_job.rs b/crates/aionui-db/src/models/cron_job.rs index 33b360ee5..c091f3a60 100644 --- a/crates/aionui-db/src/models/cron_job.rs +++ b/crates/aionui-db/src/models/cron_job.rs @@ -16,7 +16,6 @@ pub struct CronJobRow { pub agent_config: Option, pub conversation_id: String, pub conversation_title: Option, - pub agent_type: String, pub created_by: String, pub skill_content: Option, pub description: Option, @@ -50,7 +49,6 @@ mod tests { agent_config: Some(r#"{"backend":"openai"}"#.into()), conversation_id: "conv_xyz".into(), conversation_title: Some("Reports".into()), - agent_type: "openai".into(), created_by: "user".into(), skill_content: Some("---\nname: test\n---\nDo something".into()), description: Some("A test cron job".into()), @@ -88,7 +86,6 @@ mod tests { agent_config: None, conversation_id: "conv_1".into(), conversation_title: None, - agent_type: "acp".into(), created_by: "agent".into(), skill_content: None, description: None, diff --git a/crates/aionui-db/src/repository/assistant.rs b/crates/aionui-db/src/repository/assistant.rs index a46aaa5c9..e8a9659ba 100644 --- a/crates/aionui-db/src/repository/assistant.rs +++ b/crates/aionui-db/src/repository/assistant.rs @@ -60,30 +60,30 @@ pub trait IAssistantOverrideRepository: Send + Sync { #[async_trait::async_trait] pub trait IAssistantDefinitionRepository: Send + Sync { async fn list(&self) -> Result, DbError>; - async fn get_by_key(&self, assistant_key: &str) -> Result, DbError>; - async fn get_by_definition_id(&self, definition_id: &str) -> Result, DbError>; + async fn get_by_assistant_id(&self, assistant_id: &str) -> Result, DbError>; + async fn get_by_id(&self, id: &str) -> Result, DbError>; async fn get_by_source_ref( &self, source: &str, source_ref: &str, ) -> Result, DbError>; async fn upsert(&self, params: &UpsertAssistantDefinitionParams<'_>) -> Result; - async fn soft_delete(&self, definition_id: &str, deleted_at: i64) -> Result; + async fn soft_delete(&self, id: &str, deleted_at: i64) -> Result; } /// Runtime per-user assistant overlay used by the current app version. #[async_trait::async_trait] pub trait IAssistantOverlayRepository: Send + Sync { - async fn get(&self, definition_id: &str) -> Result, DbError>; + async fn get(&self, assistant_definition_id: &str) -> Result, DbError>; async fn list(&self) -> Result, DbError>; async fn upsert(&self, params: &UpsertAssistantOverlayParams<'_>) -> Result; - async fn delete(&self, definition_id: &str) -> Result; + async fn delete(&self, assistant_definition_id: &str) -> Result; } /// Assistant-scoped "auto remember last" preferences. #[async_trait::async_trait] pub trait IAssistantPreferenceRepository: Send + Sync { - async fn get(&self, definition_id: &str) -> Result, DbError>; + async fn get(&self, assistant_definition_id: &str) -> Result, DbError>; async fn upsert(&self, params: &UpsertAssistantPreferenceParams<'_>) -> Result; - async fn delete(&self, definition_id: &str) -> Result; + async fn delete(&self, assistant_definition_id: &str) -> Result; } diff --git a/crates/aionui-db/src/repository/cron.rs b/crates/aionui-db/src/repository/cron.rs index f0cb24267..90e8c52f1 100644 --- a/crates/aionui-db/src/repository/cron.rs +++ b/crates/aionui-db/src/repository/cron.rs @@ -19,7 +19,6 @@ pub struct UpdateCronJobParams { pub agent_config: Option>, pub conversation_id: Option, pub conversation_title: Option>, - pub agent_type: Option, pub skill_content: Option>, pub description: Option>, pub next_run_at: Option>, diff --git a/crates/aionui-db/src/repository/sqlite_assistant.rs b/crates/aionui-db/src/repository/sqlite_assistant.rs index 63d2b9d0b..6f98e9108 100644 --- a/crates/aionui-db/src/repository/sqlite_assistant.rs +++ b/crates/aionui-db/src/repository/sqlite_assistant.rs @@ -375,21 +375,21 @@ impl IAssistantDefinitionRepository for SqliteAssistantDefinitionRepository { Ok(rows) } - async fn get_by_key(&self, assistant_key: &str) -> Result, DbError> { + async fn get_by_assistant_id(&self, assistant_id: &str) -> Result, DbError> { let row = sqlx::query_as::<_, AssistantDefinitionRow>( - "SELECT * FROM assistant_definitions WHERE assistant_key = ? AND deleted_at IS NULL", + "SELECT * FROM assistant_definitions WHERE assistant_id = ? AND deleted_at IS NULL", ) - .bind(assistant_key) + .bind(assistant_id) .fetch_optional(&self.pool) .await?; Ok(row) } - async fn get_by_definition_id(&self, definition_id: &str) -> Result, DbError> { + async fn get_by_id(&self, id: &str) -> Result, DbError> { let row = sqlx::query_as::<_, AssistantDefinitionRow>( - "SELECT * FROM assistant_definitions WHERE definition_id = ? AND deleted_at IS NULL", + "SELECT * FROM assistant_definitions WHERE id = ? AND deleted_at IS NULL", ) - .bind(definition_id) + .bind(id) .fetch_optional(&self.pool) .await?; Ok(row) @@ -415,7 +415,7 @@ impl IAssistantDefinitionRepository for SqliteAssistantDefinitionRepository { sqlx::query( "INSERT INTO assistant_definitions ( - definition_id, assistant_key, source, owner_type, source_ref, source_version, source_hash, + id, assistant_id, source, owner_type, source_ref, source_version, source_hash, name, name_i18n, description, description_i18n, avatar_type, avatar_value, agent_id, rule_resource_type, rule_resource_ref, rule_inline_content, recommended_prompts, recommended_prompts_i18n, @@ -425,8 +425,8 @@ impl IAssistantDefinitionRepository for SqliteAssistantDefinitionRepository { default_mcps_mode, default_mcp_ids, created_at, updated_at, deleted_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL) - ON CONFLICT(definition_id) DO UPDATE SET - assistant_key = excluded.assistant_key, + ON CONFLICT(id) DO UPDATE SET + assistant_id = excluded.assistant_id, source = excluded.source, owner_type = excluded.owner_type, source_ref = excluded.source_ref, @@ -457,8 +457,8 @@ impl IAssistantDefinitionRepository for SqliteAssistantDefinitionRepository { updated_at = excluded.updated_at, deleted_at = NULL", ) - .bind(params.definition_id) - .bind(params.assistant_key) + .bind(params.id) + .bind(params.assistant_id) .bind(params.source) .bind(params.owner_type) .bind(params.source_ref) @@ -491,23 +491,23 @@ impl IAssistantDefinitionRepository for SqliteAssistantDefinitionRepository { .execute(&self.pool) .await?; - self.get_by_definition_id(params.definition_id).await?.ok_or_else(|| { + self.get_by_id(params.id).await?.ok_or_else(|| { DbError::Init(format!( - "upsert did not produce definition row for definition_id '{}'", - params.definition_id + "upsert did not produce assistant definition row for id '{}'", + params.id )) }) } - async fn soft_delete(&self, definition_id: &str, deleted_at: i64) -> Result { + async fn soft_delete(&self, id: &str, deleted_at: i64) -> Result { let result = sqlx::query( "UPDATE assistant_definitions SET deleted_at = ?, updated_at = ? - WHERE definition_id = ? AND deleted_at IS NULL", + WHERE id = ? AND deleted_at IS NULL", ) .bind(deleted_at) .bind(now_ms()) - .bind(definition_id) + .bind(id) .execute(&self.pool) .await?; Ok(result.rows_affected() > 0) @@ -516,11 +516,13 @@ impl IAssistantDefinitionRepository for SqliteAssistantDefinitionRepository { #[async_trait::async_trait] impl IAssistantOverlayRepository for SqliteAssistantOverlayRepository { - async fn get(&self, definition_id: &str) -> Result, DbError> { - let row = sqlx::query_as::<_, AssistantOverlayRow>("SELECT * FROM assistant_overlays WHERE definition_id = ?") - .bind(definition_id) - .fetch_optional(&self.pool) - .await?; + async fn get(&self, assistant_definition_id: &str) -> Result, DbError> { + let row = sqlx::query_as::<_, AssistantOverlayRow>( + "SELECT * FROM assistant_overlays WHERE assistant_definition_id = ?", + ) + .bind(assistant_definition_id) + .fetch_optional(&self.pool) + .await?; Ok(row) } @@ -537,16 +539,16 @@ impl IAssistantOverlayRepository for SqliteAssistantOverlayRepository { let now = now_ms(); sqlx::query( "INSERT INTO assistant_overlays ( - definition_id, enabled, sort_order, agent_id_override, last_used_at, created_at, updated_at + assistant_definition_id, enabled, sort_order, agent_id_override, last_used_at, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(definition_id) DO UPDATE SET + ON CONFLICT(assistant_definition_id) DO UPDATE SET enabled = excluded.enabled, sort_order = excluded.sort_order, agent_id_override = excluded.agent_id_override, last_used_at = excluded.last_used_at, updated_at = excluded.updated_at", ) - .bind(params.definition_id) + .bind(params.assistant_definition_id) .bind(params.enabled) .bind(params.sort_order) .bind(params.agent_id_override) @@ -556,17 +558,17 @@ impl IAssistantOverlayRepository for SqliteAssistantOverlayRepository { .execute(&self.pool) .await?; - self.get(params.definition_id).await?.ok_or_else(|| { + self.get(params.assistant_definition_id).await?.ok_or_else(|| { DbError::Init(format!( - "upsert did not produce state row for definition_id '{}'", - params.definition_id + "upsert did not produce overlay row for assistant_definition_id '{}'", + params.assistant_definition_id )) }) } - async fn delete(&self, definition_id: &str) -> Result { - let result = sqlx::query("DELETE FROM assistant_overlays WHERE definition_id = ?") - .bind(definition_id) + async fn delete(&self, assistant_definition_id: &str) -> Result { + let result = sqlx::query("DELETE FROM assistant_overlays WHERE assistant_definition_id = ?") + .bind(assistant_definition_id) .execute(&self.pool) .await?; Ok(result.rows_affected() > 0) @@ -575,12 +577,13 @@ impl IAssistantOverlayRepository for SqliteAssistantOverlayRepository { #[async_trait::async_trait] impl IAssistantPreferenceRepository for SqliteAssistantPreferenceRepository { - async fn get(&self, definition_id: &str) -> Result, DbError> { - let row = - sqlx::query_as::<_, AssistantPreferenceRow>("SELECT * FROM assistant_preferences WHERE definition_id = ?") - .bind(definition_id) - .fetch_optional(&self.pool) - .await?; + async fn get(&self, assistant_definition_id: &str) -> Result, DbError> { + let row = sqlx::query_as::<_, AssistantPreferenceRow>( + "SELECT * FROM assistant_preferences WHERE assistant_definition_id = ?", + ) + .bind(assistant_definition_id) + .fetch_optional(&self.pool) + .await?; Ok(row) } @@ -588,10 +591,10 @@ impl IAssistantPreferenceRepository for SqliteAssistantPreferenceRepository { let now = now_ms(); sqlx::query( "INSERT INTO assistant_preferences ( - definition_id, last_model_id, last_permission_value, last_skill_ids, + assistant_definition_id, last_model_id, last_permission_value, last_skill_ids, last_disabled_builtin_skill_ids, last_mcp_ids, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(definition_id) DO UPDATE SET + ON CONFLICT(assistant_definition_id) DO UPDATE SET last_model_id = excluded.last_model_id, last_permission_value = excluded.last_permission_value, last_skill_ids = excluded.last_skill_ids, @@ -599,7 +602,7 @@ impl IAssistantPreferenceRepository for SqliteAssistantPreferenceRepository { last_mcp_ids = excluded.last_mcp_ids, updated_at = excluded.updated_at", ) - .bind(params.definition_id) + .bind(params.assistant_definition_id) .bind(params.last_model_id) .bind(params.last_permission_value) .bind(params.last_skill_ids) @@ -610,17 +613,17 @@ impl IAssistantPreferenceRepository for SqliteAssistantPreferenceRepository { .execute(&self.pool) .await?; - self.get(params.definition_id).await?.ok_or_else(|| { + self.get(params.assistant_definition_id).await?.ok_or_else(|| { DbError::Init(format!( - "upsert did not produce preference row for definition_id '{}'", - params.definition_id + "upsert did not produce preference row for assistant_definition_id '{}'", + params.assistant_definition_id )) }) } - async fn delete(&self, definition_id: &str) -> Result { - let result = sqlx::query("DELETE FROM assistant_preferences WHERE definition_id = ?") - .bind(definition_id) + async fn delete(&self, assistant_definition_id: &str) -> Result { + let result = sqlx::query("DELETE FROM assistant_preferences WHERE assistant_definition_id = ?") + .bind(assistant_definition_id) .execute(&self.pool) .await?; Ok(result.rows_affected() > 0) @@ -670,7 +673,7 @@ pub async fn rebuild_legacy_assistant_mirror( prompts_i18n = excluded.prompts_i18n, updated_at = excluded.updated_at", ) - .bind(&definition.assistant_key) + .bind(&definition.assistant_id) .bind(&definition.name) .bind(&definition.description) .bind(&definition.avatar_value) @@ -689,7 +692,7 @@ pub async fn rebuild_legacy_assistant_mirror( .await?; } else { sqlx::query("DELETE FROM assistants WHERE id = ?") - .bind(&definition.assistant_key) + .bind(&definition.assistant_id) .execute(pool) .await?; } @@ -717,7 +720,7 @@ pub async fn rebuild_legacy_assistant_mirror( last_used_at = excluded.last_used_at, updated_at = excluded.updated_at", ) - .bind(&definition.assistant_key) + .bind(&definition.assistant_id) .bind(enabled) .bind(sort_order) .bind(agent_id_override) @@ -789,8 +792,8 @@ mod tests { fn definition_params<'a>(id: &'a str, name: &'a str) -> UpsertAssistantDefinitionParams<'a> { UpsertAssistantDefinitionParams { - definition_id: "asstdef_u1", - assistant_key: id, + id: "asstdef_u1", + assistant_id: id, source: "user", owner_type: "user", source_ref: Some(id), @@ -1085,12 +1088,12 @@ mod tests { async fn definition_upsert_then_get() { let (d, _s, _p, _db) = setup_v2().await; let row = d.upsert(&definition_params("u1", "User One")).await.unwrap(); - assert_eq!(row.assistant_key, "u1"); - assert_eq!(row.definition_id, "asstdef_u1"); + assert_eq!(row.assistant_id, "u1"); + assert_eq!(row.id, "asstdef_u1"); assert_eq!(row.source, "user"); assert_eq!(row.default_permission_mode, "fixed"); - let fetched = d.get_by_key("u1").await.unwrap().unwrap(); + let fetched = d.get_by_assistant_id("u1").await.unwrap().unwrap(); assert_eq!(fetched.name, "User One"); assert_eq!(fetched.rule_inline_content.as_deref(), Some("# rule")); assert_eq!(fetched.avatar_type, "emoji"); @@ -1102,7 +1105,7 @@ mod tests { let (d, s, _p, _db) = setup_v2().await; let definition = d.upsert(&definition_params("u1", "User One")).await.unwrap(); s.upsert(&UpsertAssistantOverlayParams { - definition_id: &definition.definition_id, + assistant_definition_id: &definition.id, enabled: false, sort_order: 9, agent_id_override: Some("claude"), @@ -1113,7 +1116,7 @@ mod tests { let list = s.list().await.unwrap(); assert_eq!(list.len(), 1); - assert_eq!(list[0].definition_id, definition.definition_id); + assert_eq!(list[0].assistant_definition_id, definition.id); assert!(!list[0].enabled); assert_eq!(list[0].sort_order, 9); assert_eq!(list[0].agent_id_override.as_deref(), Some("claude")); @@ -1125,7 +1128,7 @@ mod tests { let definition = d.upsert(&definition_params("u1", "User One")).await.unwrap(); let row = p .upsert(&UpsertAssistantPreferenceParams { - definition_id: &definition.definition_id, + assistant_definition_id: &definition.id, last_model_id: Some("gpt-4.1"), last_permission_value: Some("workspace-write"), last_skill_ids: r#"["pdf"]"#, @@ -1136,7 +1139,7 @@ mod tests { .unwrap(); assert_eq!(row.last_model_id.as_deref(), Some("gpt-4.1")); - let fetched = p.get(&definition.definition_id).await.unwrap().unwrap(); + let fetched = p.get(&definition.id).await.unwrap().unwrap(); assert_eq!(fetched.last_skill_ids, r#"["pdf"]"#); } @@ -1146,7 +1149,7 @@ mod tests { let definition = d.upsert(&definition_params("u1", "User One")).await.unwrap(); let state = s .upsert(&UpsertAssistantOverlayParams { - definition_id: &definition.definition_id, + assistant_definition_id: &definition.id, enabled: false, sort_order: 7, agent_id_override: Some("claude"), @@ -1181,12 +1184,12 @@ mod tests { async fn rebuild_legacy_mirror_writes_runtime_backend_from_agent_id() { let (d, s, _p, db) = setup_v2().await; let mut params = definition_params("u2", "User Two"); - params.definition_id = "asstdef_u2"; + params.id = "asstdef_u2"; params.agent_id = "cc126dd5"; let definition = d.upsert(¶ms).await.unwrap(); let state = s .upsert(&UpsertAssistantOverlayParams { - definition_id: &definition.definition_id, + assistant_definition_id: &definition.id, enabled: true, sort_order: 0, agent_id_override: Some("2d23ff1c"), @@ -1218,8 +1221,8 @@ mod tests { let (d, s, _p, db) = setup_v2().await; let definition = d .upsert(&UpsertAssistantDefinitionParams { - definition_id: "asstdef_builtin_office", - assistant_key: "builtin-office", + id: "asstdef_builtin_office", + assistant_id: "builtin-office", source: "builtin", owner_type: "system", source_ref: Some("builtin-office"), @@ -1252,7 +1255,7 @@ mod tests { .unwrap(); let state = s .upsert(&UpsertAssistantOverlayParams { - definition_id: &definition.definition_id, + assistant_definition_id: &definition.id, enabled: false, sort_order: 3, agent_id_override: Some("claude"), diff --git a/crates/aionui-db/src/repository/sqlite_conversation.rs b/crates/aionui-db/src/repository/sqlite_conversation.rs index 9de132c0c..8fc55d8d7 100644 --- a/crates/aionui-db/src/repository/sqlite_conversation.rs +++ b/crates/aionui-db/src/repository/sqlite_conversation.rs @@ -294,7 +294,7 @@ impl IConversationRepository for SqliteConversationRepository { "INSERT INTO conversation_assistant_snapshots ( conversation_id, assistant_definition_id, - assistant_key, + assistant_id, assistant_source, assistant_name, assistant_avatar_type, @@ -315,7 +315,7 @@ impl IConversationRepository for SqliteConversationRepository { ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(conversation_id) DO UPDATE SET assistant_definition_id = excluded.assistant_definition_id, - assistant_key = excluded.assistant_key, + assistant_id = excluded.assistant_id, assistant_source = excluded.assistant_source, assistant_name = excluded.assistant_name, assistant_avatar_type = excluded.assistant_avatar_type, @@ -335,7 +335,7 @@ impl IConversationRepository for SqliteConversationRepository { ) .bind(params.conversation_id) .bind(params.assistant_definition_id) - .bind(params.assistant_key) + .bind(params.assistant_id) .bind(params.assistant_source) .bind(params.assistant_name) .bind(params.assistant_avatar_type) diff --git a/crates/aionui-db/src/repository/sqlite_cron.rs b/crates/aionui-db/src/repository/sqlite_cron.rs index 333f485d3..d11df02df 100644 --- a/crates/aionui-db/src/repository/sqlite_cron.rs +++ b/crates/aionui-db/src/repository/sqlite_cron.rs @@ -23,11 +23,11 @@ impl ICronRepository for SqliteCronRepository { "INSERT INTO cron_jobs (\ id, name, enabled, schedule_kind, schedule_value, schedule_tz, \ schedule_description, payload_message, execution_mode, agent_config, \ - conversation_id, conversation_title, agent_type, created_by, \ + conversation_id, conversation_title, created_by, \ skill_content, description, created_at, updated_at, next_run_at, last_run_at, \ last_status, last_error, run_count, retry_count, max_retries\ ) VALUES (\ - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?\ + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?\ )", ) .bind(&row.id) @@ -42,7 +42,6 @@ impl ICronRepository for SqliteCronRepository { .bind(&row.agent_config) .bind(&row.conversation_id) .bind(&row.conversation_title) - .bind(&row.agent_type) .bind(&row.created_by) .bind(&row.skill_content) .bind(&row.description) @@ -115,7 +114,6 @@ impl ICronRepository for SqliteCronRepository { push_opt_str!(agent_config); push_str!(conversation_id); push_opt_str!(conversation_title); - push_str!(agent_type); push_opt_str!(skill_content); push_opt_str!(description); push_opt_i64!(next_run_at); @@ -266,7 +264,6 @@ mod tests { agent_config: None, conversation_id: "conv_1".into(), conversation_title: Some("Test Conv".into()), - agent_type: "acp".into(), created_by: "user".into(), skill_content: None, description: None, @@ -520,12 +517,11 @@ mod tests { async fn insert_with_agent_config_json() { let (repo, _db) = setup().await; let mut row = make_row("cron_ac"); - row.agent_config = Some(r#"{"backend":"openai","name":"GPT","modelId":"gpt-4"}"#.into()); + row.agent_config = Some(r#"{"name":"GPT","model_id":"gpt-4"}"#.into()); repo.insert(&row).await.unwrap(); let found = repo.get_by_id("cron_ac").await.unwrap().unwrap(); let config = found.agent_config.unwrap(); - assert!(config.contains("openai")); assert!(config.contains("gpt-4")); } } diff --git a/crates/aionui-db/tests/assistant_data_unification_schema.rs b/crates/aionui-db/tests/assistant_data_unification_schema.rs index 890b3f9a9..c3ce20718 100644 --- a/crates/aionui-db/tests/assistant_data_unification_schema.rs +++ b/crates/aionui-db/tests/assistant_data_unification_schema.rs @@ -45,8 +45,9 @@ async fn assistant_definition_table_has_expected_default_columns() { "assistant_definitions should exist before inspecting columns" ); - assert!(columns.iter().any(|name| name == "definition_id")); - assert!(columns.iter().any(|name| name == "assistant_key")); + assert!(columns.iter().any(|name| name == "id")); + assert!(columns.iter().any(|name| name == "assistant_id")); + assert!(!columns.iter().any(|name| name == "assistant_key")); assert!(columns.iter().any(|name| name == "default_model_mode")); assert!(columns.iter().any(|name| name == "default_permission_mode")); assert!(columns.iter().any(|name| name == "default_skill_ids")); @@ -58,14 +59,14 @@ async fn assistant_definition_table_has_expected_default_columns() { .fetch_all(db.pool()) .await .unwrap_or_default(); - assert!(overlay_columns.iter().any(|name| name == "definition_id")); + assert!(overlay_columns.iter().any(|name| name == "assistant_definition_id")); let preference_columns: Vec = sqlx::query_scalar("SELECT name FROM pragma_table_info('assistant_preferences')") .fetch_all(db.pool()) .await .unwrap_or_default(); - assert!(preference_columns.iter().any(|name| name == "definition_id")); + assert!(preference_columns.iter().any(|name| name == "assistant_definition_id")); let snapshot_columns: Vec = sqlx::query_scalar("SELECT name FROM pragma_table_info('conversation_assistant_snapshots')") @@ -74,7 +75,7 @@ async fn assistant_definition_table_has_expected_default_columns() { .unwrap_or_default(); assert!(snapshot_columns.iter().any(|name| name == "conversation_id")); assert!(snapshot_columns.iter().any(|name| name == "assistant_definition_id")); - assert!(snapshot_columns.iter().any(|name| name == "assistant_key")); + assert!(snapshot_columns.iter().any(|name| name == "assistant_id")); assert!(snapshot_columns.iter().any(|name| name == "default_model_mode")); assert!(snapshot_columns.iter().any(|name| name == "resolved_model_id")); assert!(snapshot_columns.iter().any(|name| name == "resolved_skill_ids")); @@ -116,7 +117,7 @@ async fn assistant_definition_table_rejects_extension_source_and_owner_type() { let source_err = sqlx::query( r#" INSERT INTO assistant_definitions ( - definition_id, assistant_key, source, owner_type, source_ref, + id, assistant_id, source, owner_type, source_ref, name, name_i18n, description_i18n, avatar_type, agent_id, rule_resource_type, recommended_prompts, recommended_prompts_i18n, default_model_mode, default_permission_mode, default_skills_mode, default_skill_ids, @@ -140,7 +141,7 @@ async fn assistant_definition_table_rejects_extension_source_and_owner_type() { let owner_err = sqlx::query( r#" INSERT INTO assistant_definitions ( - definition_id, assistant_key, source, owner_type, source_ref, + id, assistant_id, source, owner_type, source_ref, name, name_i18n, description_i18n, avatar_type, agent_id, rule_resource_type, recommended_prompts, recommended_prompts_i18n, default_model_mode, default_permission_mode, default_skills_mode, default_skill_ids, diff --git a/crates/aionui-db/tests/cron_assistant_first_migration.rs b/crates/aionui-db/tests/cron_assistant_first_migration.rs new file mode 100644 index 000000000..3b07739ad --- /dev/null +++ b/crates/aionui-db/tests/cron_assistant_first_migration.rs @@ -0,0 +1,311 @@ +use std::borrow::Cow; +use std::path::Path; + +use sqlx::Row; +use sqlx::migrate::Migrator; +use sqlx::sqlite::SqlitePoolOptions; + +async fn run_migrations_through(pool: &sqlx::SqlitePool, max_version: i64) { + let full = Migrator::new(Path::new("migrations")).await.unwrap(); + let migrations = full + .migrations + .iter() + .filter(|migration| migration.version <= max_version) + .cloned() + .collect::>(); + let migrator = Migrator { + migrations: Cow::Owned(migrations), + ignore_missing: false, + locking: true, + no_tx: false, + }; + migrator.run(pool).await.unwrap(); +} + +async fn run_migration(pool: &sqlx::SqlitePool, version: i64) { + let full = Migrator::new(Path::new("migrations")).await.unwrap(); + let migrations = full + .migrations + .iter() + .filter(|migration| migration.version == version) + .cloned() + .collect::>(); + let migrator = Migrator { + migrations: Cow::Owned(migrations), + ignore_missing: true, + locking: true, + no_tx: false, + }; + migrator.run(pool).await.unwrap(); +} + +async fn seed_legacy_assistant_identity(pool: &sqlx::SqlitePool) { + sqlx::query( + "INSERT INTO users (id, username, password_hash, created_at, updated_at) + VALUES ('user_1', 'user_1', '', 1, 1)", + ) + .execute(pool) + .await + .unwrap(); + + for (id, backend, agent_type, name, source, sort_order) in [ + ("agent-aionrs", "", "aionrs", "Aion CLI", "internal", 100), + ("agent-codex", "codex", "acp", "Codex CLI", "builtin", 200), + ("agent-claude", "claude", "acp", "Claude Code", "builtin", 210), + ] { + sqlx::query( + "INSERT INTO agent_metadata ( + id, name, backend, command, agent_type, enabled, agent_source, sort_order, created_at, updated_at + ) VALUES (?, ?, NULLIF(?, ''), '', ?, 1, ?, ?, 1, 1)", + ) + .bind(id) + .bind(name) + .bind(backend) + .bind(agent_type) + .bind(source) + .bind(sort_order) + .execute(pool) + .await + .unwrap(); + } + + for (definition_id, assistant_key, agent_backend, source_ref) in [ + ("def-aionrs", "aionui-assistant", "aionrs", "aionui-assistant"), + ("def-codex", "bare:agent-codex", "codex", "agent-codex"), + ("def-claude", "bare:agent-claude", "claude", "agent-claude"), + ] { + sqlx::query( + "INSERT INTO assistant_definitions ( + definition_id, assistant_key, source, owner_type, source_ref, + name, name_i18n, description_i18n, avatar_type, agent_backend, + rule_resource_type, recommended_prompts, recommended_prompts_i18n, + default_model_mode, default_permission_mode, default_skills_mode, default_skill_ids, + custom_skill_names, default_disabled_builtin_skill_ids, default_mcps_mode, default_mcp_ids, + created_at, updated_at + ) VALUES (?, ?, 'generated', 'system', ?, ?, '{}', '{}', 'none', ?, + 'none', '[]', '{}', 'auto', 'auto', 'auto', '[]', '[]', '[]', 'auto', '[]', 1, 1)", + ) + .bind(definition_id) + .bind(assistant_key) + .bind(source_ref) + .bind(assistant_key) + .bind(agent_backend) + .execute(pool) + .await + .unwrap(); + } + + for (conversation_id, name, agent_type, extra) in [ + ("conv_aionrs", "Aion cron", "aionrs", r#"{"workspace":"/tmp/aionrs"}"#), + ( + "conv_snapshot", + "Snapshot cron", + "acp", + r#"{"workspace":"/tmp/snapshot"}"#, + ), + ( + "conv_agent_type", + "Agent type cron", + "acp", + r#"{"workspace":"/tmp/agent-type"}"#, + ), + ( + "conv_missing", + "Missing assistant cron", + "acp", + r#"{"workspace":"/tmp/missing"}"#, + ), + ( + "conv_invalid", + "Invalid JSON cron", + "acp", + r#"{"workspace":"/tmp/invalid"}"#, + ), + ] { + sqlx::query( + "INSERT INTO conversations (id, user_id, name, type, extra, created_at, updated_at) + VALUES (?, 'user_1', ?, ?, ?, 1, 1)", + ) + .bind(conversation_id) + .bind(name) + .bind(agent_type) + .bind(extra) + .execute(pool) + .await + .unwrap(); + } + + sqlx::query( + "INSERT INTO conversation_assistant_snapshots ( + conversation_id, assistant_definition_id, assistant_key, assistant_source, assistant_name, + assistant_avatar_type, agent_backend, rules_content, + default_model_mode, default_permission_mode, default_skills_mode, + default_mcps_mode, created_at, updated_at + ) VALUES ( + 'conv_snapshot', 'def-codex', 'bare:agent-codex', 'generated', 'Codex', + 'none', 'codex', '', 'auto', 'auto', 'auto', 'auto', 1, 1 + )", + ) + .execute(pool) + .await + .unwrap(); +} + +async fn insert_legacy_cron( + pool: &sqlx::SqlitePool, + id: &str, + conversation_id: &str, + agent_type: &str, + agent_config: &str, +) { + sqlx::query( + "INSERT INTO cron_jobs ( + id, name, enabled, schedule_kind, schedule_value, payload_message, + execution_mode, agent_config, conversation_id, agent_type, created_by, + created_at, updated_at, run_count, retry_count, max_retries + ) VALUES (?, ?, 1, 'every', '60000', 'run', 'new_conversation', ?, ?, ?, 'user', 1, 1, 0, 0, 3)", + ) + .bind(id) + .bind(id) + .bind(agent_config) + .bind(conversation_id) + .bind(agent_type) + .execute(pool) + .await + .unwrap(); +} + +#[tokio::test] +async fn migration_013_normalizes_legacy_cron_agent_identity() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + + run_migrations_through(&pool, 12).await; + seed_legacy_assistant_identity(&pool).await; + + insert_legacy_cron( + &pool, + "cron_aionrs", + "conv_aionrs", + "aionrs", + r#"{"backend":"provider-1","name":"Aion","assistant_id":"aionui-assistant","model_id":"gpt-5"}"#, + ) + .await; + insert_legacy_cron( + &pool, + "cron_snapshot", + "conv_snapshot", + "codex", + r#"{"backend":"codex","name":"Codex"}"#, + ) + .await; + insert_legacy_cron( + &pool, + "cron_agent_type", + "conv_agent_type", + "claude", + r#"{"backend":"claude","name":"Claude"}"#, + ) + .await; + insert_legacy_cron( + &pool, + "cron_missing", + "conv_missing", + "ghost", + r#"{"backend":"ghost","name":"Ghost"}"#, + ) + .await; + insert_legacy_cron(&pool, "cron_invalid", "conv_invalid", "codex", r#"{"backend":"codex""#).await; + + run_migration(&pool, 13).await; + + let cron_columns: Vec = sqlx::query_scalar("SELECT name FROM pragma_table_info('cron_jobs')") + .fetch_all(&pool) + .await + .unwrap(); + assert!(!cron_columns.iter().any(|column| column == "agent_type")); + + let backend_key_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM cron_jobs + WHERE agent_config IS NOT NULL + AND json_valid(agent_config) + AND json_type(agent_config, '$.backend') IS NOT NULL", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(backend_key_count, 0); + + let aionrs = sqlx::query( + "SELECT + json_extract(agent_config, '$.assistant_id') AS assistant_id, + json_extract(agent_config, '$.model.provider_id') AS provider_id, + json_extract(agent_config, '$.model.model') AS model, + enabled + FROM cron_jobs WHERE id = 'cron_aionrs'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(aionrs.get::("assistant_id"), "aionui-assistant"); + assert_eq!(aionrs.get::("provider_id"), "provider-1"); + assert_eq!(aionrs.get::("model"), "gpt-5"); + assert_eq!(aionrs.get::("enabled"), 1); + + let snapshot_assistant_id: String = sqlx::query_scalar( + "SELECT json_extract(agent_config, '$.assistant_id') + FROM cron_jobs WHERE id = 'cron_snapshot'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(snapshot_assistant_id, "bare:agent-codex"); + + let recovered_assistant_id: String = sqlx::query_scalar( + "SELECT json_extract(agent_config, '$.assistant_id') + FROM cron_jobs WHERE id = 'cron_agent_type'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(recovered_assistant_id, "bare:agent-claude"); + + let disabled_rows = sqlx::query( + "SELECT id, enabled, last_status, last_error + FROM cron_jobs + WHERE id IN ('cron_missing', 'cron_invalid') + ORDER BY id", + ) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(disabled_rows.len(), 2); + assert_eq!(disabled_rows[0].get::("id"), "cron_invalid"); + assert_eq!(disabled_rows[0].get::("enabled"), 0); + assert_eq!(disabled_rows[0].get::("last_status"), "error"); + assert!( + disabled_rows[0] + .get::("last_error") + .contains("invalid agent_config JSON") + ); + assert_eq!(disabled_rows[1].get::("id"), "cron_missing"); + assert_eq!(disabled_rows[1].get::("enabled"), 0); + assert_eq!(disabled_rows[1].get::("last_status"), "error"); + assert!( + disabled_rows[1] + .get::("last_error") + .contains("assistant_id could not be recovered") + ); + + let index_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'index' AND name = 'idx_cron_jobs_agent_type'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(index_count, 0); +} diff --git a/crates/aionui-db/tests/cron_repository.rs b/crates/aionui-db/tests/cron_repository.rs index 0991ecbaa..316e1c793 100644 --- a/crates/aionui-db/tests/cron_repository.rs +++ b/crates/aionui-db/tests/cron_repository.rs @@ -52,7 +52,6 @@ fn make_job(id: &str) -> CronJobRow { agent_config: None, conversation_id: "conv_1".into(), conversation_title: Some("Conv 1".into()), - agent_type: "acp".into(), created_by: "user".into(), skill_content: None, description: None, @@ -85,7 +84,6 @@ async fn cj1_insert_returns_all_fields() { assert_eq!(found.payload_message, "Run report"); assert_eq!(found.execution_mode, "existing"); assert_eq!(found.conversation_id, "conv_1"); - assert_eq!(found.agent_type, "acp"); assert_eq!(found.created_by, "user"); assert_eq!(found.run_count, 0); assert_eq!(found.retry_count, 0); diff --git a/crates/aionui-team/src/guide/server.rs b/crates/aionui-team/src/guide/server.rs index 5ca01d31a..de74fdd6e 100644 --- a/crates/aionui-team/src/guide/server.rs +++ b/crates/aionui-team/src/guide/server.rs @@ -599,15 +599,15 @@ mod tests { Ok(vec![self.row.clone()]) } - async fn get_by_key(&self, assistant_key: &str) -> Result, aionui_db::DbError> { - Ok((self.row.assistant_key == assistant_key).then_some(self.row.clone())) - } - - async fn get_by_definition_id( + async fn get_by_assistant_id( &self, - definition_id: &str, + assistant_id: &str, ) -> Result, aionui_db::DbError> { - Ok((self.row.definition_id == definition_id).then_some(self.row.clone())) + Ok((self.row.assistant_id == assistant_id).then_some(self.row.clone())) + } + + async fn get_by_id(&self, definition_id: &str) -> Result, aionui_db::DbError> { + Ok((self.row.id == definition_id).then_some(self.row.clone())) } async fn get_by_source_ref( @@ -637,7 +637,7 @@ mod tests { #[async_trait::async_trait] impl IAssistantOverlayRepository for SingleAssistantOverlayRepo { async fn get(&self, definition_id: &str) -> Result, aionui_db::DbError> { - Ok((self.row.definition_id == definition_id).then_some(self.row.clone())) + Ok((self.row.assistant_definition_id == definition_id).then_some(self.row.clone())) } async fn list(&self) -> Result, aionui_db::DbError> { @@ -839,8 +839,8 @@ mod tests { async fn service_backed_list_models_appends_gemini_to_backends() { let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { row: AssistantDefinitionRow { - definition_id: "def-guide-models".into(), - assistant_key: "assistant-models".into(), + id: "def-guide-models".into(), + assistant_id: "assistant-models".into(), source: "user".into(), owner_type: "user".into(), source_ref: None, @@ -875,7 +875,7 @@ mod tests { }); let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { row: AssistantOverlayRow { - definition_id: "def-guide-models".into(), + assistant_definition_id: "def-guide-models".into(), enabled: true, sort_order: 0, agent_id_override: None, @@ -921,8 +921,8 @@ mod tests { async fn create_team_uses_assistant_identity_from_caller_conversation() { let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { row: AssistantDefinitionRow { - definition_id: "def-guide-lead".into(), - assistant_key: "assistant-lead".into(), + id: "def-guide-lead".into(), + assistant_id: "assistant-lead".into(), source: "user".into(), owner_type: "user".into(), source_ref: None, @@ -957,7 +957,7 @@ mod tests { }); let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { row: AssistantOverlayRow { - definition_id: "def-guide-lead".into(), + assistant_definition_id: "def-guide-lead".into(), enabled: true, sort_order: 0, agent_id_override: Some("codex".into()), @@ -1033,8 +1033,8 @@ mod tests { async fn create_team_opens_active_team_run_for_assistant_first_tools() { let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { row: AssistantDefinitionRow { - definition_id: "def-guide-teamrun".into(), - assistant_key: "assistant-teamrun".into(), + id: "def-guide-teamrun".into(), + assistant_id: "assistant-teamrun".into(), source: "user".into(), owner_type: "user".into(), source_ref: None, @@ -1069,7 +1069,7 @@ mod tests { }); let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { row: AssistantOverlayRow { - definition_id: "def-guide-teamrun".into(), + assistant_definition_id: "def-guide-teamrun".into(), enabled: true, sort_order: 0, agent_id_override: Some("claude".into()), @@ -1135,8 +1135,8 @@ mod tests { async fn create_team_next_step_does_not_echo_backend_only_teammate_plan() { let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { row: AssistantDefinitionRow { - definition_id: "def-guide-summary".into(), - assistant_key: "assistant-lead".into(), + id: "def-guide-summary".into(), + assistant_id: "assistant-lead".into(), source: "user".into(), owner_type: "user".into(), source_ref: None, @@ -1171,7 +1171,7 @@ mod tests { }); let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { row: AssistantOverlayRow { - definition_id: "def-guide-summary".into(), + assistant_definition_id: "def-guide-summary".into(), enabled: true, sort_order: 0, agent_id_override: Some("claude".into()), @@ -1238,8 +1238,8 @@ mod tests { async fn create_team_requires_explicit_assistant_id_for_non_assistant_backed_caller() { let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { row: AssistantDefinitionRow { - definition_id: "def-guide-non-assistant-caller".into(), - assistant_key: "assistant-unused".into(), + id: "def-guide-non-assistant-caller".into(), + assistant_id: "assistant-unused".into(), source: "user".into(), owner_type: "user".into(), source_ref: None, @@ -1274,7 +1274,7 @@ mod tests { }); let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { row: AssistantOverlayRow { - definition_id: "def-guide-non-assistant-caller".into(), + assistant_definition_id: "def-guide-non-assistant-caller".into(), enabled: true, sort_order: 0, agent_id_override: Some("codex".into()), diff --git a/crates/aionui-team/src/mcp/server.rs b/crates/aionui-team/src/mcp/server.rs index fb7f82868..e0e86cf18 100644 --- a/crates/aionui-team/src/mcp/server.rs +++ b/crates/aionui-team/src/mcp/server.rs @@ -548,7 +548,7 @@ async fn exec_describe_assistant(args: &Value, service: &Weak, assistant_id: Option<&str>, ) -> Result { - let assistant_key = assistant_id.map(str::trim).filter(|value| !value.is_empty()); - if let Some(assistant_key) = assistant_key { + let assistant_id = assistant_id.map(str::trim).filter(|value| !value.is_empty()); + if let Some(assistant_id) = assistant_id { let definition = self .assistant_definition_repo - .get_by_key(assistant_key) + .get_by_assistant_id(assistant_id) .await? - .ok_or_else(|| TeamError::InvalidRequest(format!("Preset assistant not found: {assistant_key}")))?; - let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; + .ok_or_else(|| TeamError::InvalidRequest(format!("Preset assistant not found: {assistant_id}")))?; + let overlay = self.assistant_overlay_repo.get(&definition.id).await?; let effective_agent_id = overlay .and_then(|row| row.agent_id_override) .filter(|value| !value.trim().is_empty()) diff --git a/crates/aionui-team/src/service/describe_support.rs b/crates/aionui-team/src/service/describe_support.rs index f5bb5fff9..82cd5d70f 100644 --- a/crates/aionui-team/src/service/describe_support.rs +++ b/crates/aionui-team/src/service/describe_support.rs @@ -9,15 +9,15 @@ use aionui_db::models::AssistantDefinitionRow; impl TeamSessionService { pub(crate) async fn describe_assistant( &self, - assistant_key: &str, + assistant_id: &str, locale: Option<&str>, ) -> Result { let definition = self .assistant_definition_repo - .get_by_key(assistant_key) + .get_by_assistant_id(assistant_id) .await? - .ok_or_else(|| TeamError::InvalidRequest(format!("Preset assistant not found: {assistant_key}")))?; - let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; + .ok_or_else(|| TeamError::InvalidRequest(format!("Preset assistant not found: {assistant_id}")))?; + let overlay = self.assistant_overlay_repo.get(&definition.id).await?; let effective_agent_id = overlay .as_ref() .and_then(|row| row.agent_id_override.as_deref()) @@ -48,7 +48,7 @@ fn render_assistant_description(definition: &AssistantDefinitionRow, effective_b .collect::>(); let mut out = String::new(); - let _ = writeln!(out, "# {} (`{}`)", name, definition.assistant_key); + let _ = writeln!(out, "# {} (`{}`)", name, definition.assistant_id); let _ = writeln!(out); let _ = writeln!(out, "Backend: {effective_backend}"); let _ = writeln!(out); @@ -76,7 +76,7 @@ fn render_assistant_description(definition: &AssistantDefinitionRow, effective_b let _ = writeln!( out, "Use `team_spawn_agent` with `assistant_id=\"{}\"`.", - definition.assistant_key + definition.assistant_id ); out.trim_end().to_owned() } diff --git a/crates/aionui-team/src/service/response_builder.rs b/crates/aionui-team/src/service/response_builder.rs index 334d50903..d8807a47f 100644 --- a/crates/aionui-team/src/service/response_builder.rs +++ b/crates/aionui-team/src/service/response_builder.rs @@ -37,9 +37,9 @@ impl TeamSessionService { async fn resolve_agent_icon(&self, agent: &TeamAgent) -> Result, TeamError> { if let Some(assistant_id) = agent.assistant_id.as_deref() - && let Some(definition) = self.assistant_definition_repo.get_by_key(assistant_id).await? + && let Some(definition) = self.assistant_definition_repo.get_by_assistant_id(assistant_id).await? && let Some(icon) = assistant_icon( - definition.assistant_key.as_str(), + definition.assistant_id.as_str(), &definition.avatar_type, definition.avatar_value.as_deref(), ) @@ -69,13 +69,13 @@ impl TeamSessionService { } } -fn assistant_icon(assistant_key: &str, avatar_type: &str, avatar_value: Option<&str>) -> Option { +fn assistant_icon(assistant_id: &str, avatar_type: &str, avatar_value: Option<&str>) -> Option { match avatar_type { "builtin_asset" | "user_asset" => avatar_value.map(|value| { if is_direct_avatar_url(value) { value.to_string() } else { - format!("/api/assistants/{assistant_key}/avatar") + format!("/api/assistants/{assistant_id}/avatar") } }), _ => None, diff --git a/crates/aionui-team/src/service/spawn_support.rs b/crates/aionui-team/src/service/spawn_support.rs index 1754be6b2..4eb498158 100644 --- a/crates/aionui-team/src/service/spawn_support.rs +++ b/crates/aionui-team/src/service/spawn_support.rs @@ -84,13 +84,13 @@ impl TeamSessionService { fallback_backend: &str, fallback_model: &str, ) -> Result<(String, String), TeamError> { - if let Some(assistant_key) = assistant_id.map(str::trim).filter(|value| !value.is_empty()) { + if let Some(assistant_id) = assistant_id.map(str::trim).filter(|value| !value.is_empty()) { let definition = self .assistant_definition_repo - .get_by_key(assistant_key) + .get_by_assistant_id(assistant_id) .await? - .ok_or_else(|| TeamError::InvalidRequest(format!("Preset assistant not found: {assistant_key}")))?; - let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; + .ok_or_else(|| TeamError::InvalidRequest(format!("Preset assistant not found: {assistant_id}")))?; + let overlay = self.assistant_overlay_repo.get(&definition.id).await?; let effective_agent_id = overlay .as_ref() .and_then(|row| row.agent_id_override.as_deref()) @@ -164,8 +164,10 @@ impl TeamSessionService { return Vec::new(); }; - let overlay_by_definition: HashMap<&str, &AssistantOverlayRow> = - overlays.iter().map(|row| (row.definition_id.as_str(), row)).collect(); + let overlay_by_definition: HashMap<&str, &AssistantOverlayRow> = overlays + .iter() + .map(|row| (row.assistant_definition_id.as_str(), row)) + .collect(); let mut assistants: Vec<(i32, AvailableAssistant)> = Vec::new(); @@ -176,7 +178,7 @@ impl TeamSessionService { }) else { continue; }; - let overlay = overlay_by_definition.get(definition.definition_id.as_str()).copied(); + let overlay = overlay_by_definition.get(definition.id.as_str()).copied(); let enabled = overlay.is_none_or(|row| row.enabled); if !enabled { continue; @@ -220,7 +222,7 @@ impl TeamSessionService { assistants.push(( overlay.map(|row| row.sort_order).unwrap_or(i32::MAX), AvailableAssistant { - assistant_id: definition.assistant_key.clone(), + assistant_id: definition.assistant_id.clone(), name: definition.name.clone(), backend: effective_backend.to_owned(), description: definition.description.clone().unwrap_or_default(), @@ -251,13 +253,13 @@ impl TeamSessionService { return Ok(crate::mcp::tools::handle_team_list_models(&serde_json::Value::Null)); }; let backend_filter = match assistant_id_filter.map(str::trim).filter(|value| !value.is_empty()) { - Some(assistant_key) => { + Some(assistant_id) => { let definition = self .assistant_definition_repo - .get_by_key(assistant_key) + .get_by_assistant_id(assistant_id) .await? - .ok_or_else(|| TeamError::InvalidRequest(format!("Assistant not found: {assistant_key}")))?; - let overlay = self.assistant_overlay_repo.get(&definition.definition_id).await?; + .ok_or_else(|| TeamError::InvalidRequest(format!("Assistant not found: {assistant_id}")))?; + let overlay = self.assistant_overlay_repo.get(&definition.id).await?; Some( resolve_runtime_backend( &self.agent_metadata_repo, @@ -383,12 +385,12 @@ mod tests { Ok(vec![self.row.clone()]) } - async fn get_by_key(&self, assistant_key: &str) -> Result, DbError> { - Ok((self.row.assistant_key == assistant_key).then_some(self.row.clone())) + async fn get_by_assistant_id(&self, assistant_id: &str) -> Result, DbError> { + Ok((self.row.assistant_id == assistant_id).then_some(self.row.clone())) } - async fn get_by_definition_id(&self, definition_id: &str) -> Result, DbError> { - Ok((self.row.definition_id == definition_id).then_some(self.row.clone())) + async fn get_by_id(&self, definition_id: &str) -> Result, DbError> { + Ok((self.row.id == definition_id).then_some(self.row.clone())) } async fn get_by_source_ref( @@ -419,7 +421,7 @@ mod tests { #[async_trait::async_trait] impl IAssistantOverlayRepository for SingleAssistantOverlayRepo { async fn get(&self, definition_id: &str) -> Result, DbError> { - Ok((self.row.definition_id == definition_id).then_some(self.row.clone())) + Ok((self.row.assistant_definition_id == definition_id).then_some(self.row.clone())) } async fn list(&self) -> Result, DbError> { @@ -558,8 +560,8 @@ mod tests { svc.agent_metadata_repo.clone(), Arc::new(SingleAssistantDefinitionRepo { row: AssistantDefinitionRow { - definition_id: "def-1".into(), - assistant_key: "word-creator".into(), + id: "def-1".into(), + assistant_id: "word-creator".into(), source: "builtin".into(), owner_type: "system".into(), source_ref: Some("word-creator".into()), @@ -594,7 +596,7 @@ mod tests { }), Arc::new(SingleAssistantOverlayRepo { row: AssistantOverlayRow { - definition_id: "def-1".into(), + assistant_definition_id: "def-1".into(), enabled: true, sort_order: 0, agent_id_override: None, diff --git a/crates/aionui-team/src/test_utils.rs b/crates/aionui-team/src/test_utils.rs index ba0d278b4..08d667116 100644 --- a/crates/aionui-team/src/test_utils.rs +++ b/crates/aionui-team/src/test_utils.rs @@ -792,11 +792,11 @@ pub(crate) mod workspace_harness { Ok(vec![]) } - async fn get_by_key(&self, _assistant_key: &str) -> Result, DbError> { + async fn get_by_assistant_id(&self, _assistant_id: &str) -> Result, DbError> { Ok(None) } - async fn get_by_definition_id(&self, _definition_id: &str) -> Result, DbError> { + async fn get_by_id(&self, _definition_id: &str) -> Result, DbError> { Ok(None) } diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index 58307233c..574bca57b 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -1181,11 +1181,11 @@ impl IAssistantDefinitionRepository for EmptyAssistantDefinitionRepo { Ok(vec![]) } - async fn get_by_key(&self, _assistant_key: &str) -> Result, DbError> { + async fn get_by_assistant_id(&self, _assistant_id: &str) -> Result, DbError> { Ok(None) } - async fn get_by_definition_id(&self, _definition_id: &str) -> Result, DbError> { + async fn get_by_id(&self, _definition_id: &str) -> Result, DbError> { Ok(None) } @@ -1237,12 +1237,12 @@ impl IAssistantDefinitionRepository for SingleAssistantDefinitionRepo { Ok(vec![self.row.clone()]) } - async fn get_by_key(&self, assistant_key: &str) -> Result, DbError> { - Ok((self.row.assistant_key == assistant_key).then_some(self.row.clone())) + async fn get_by_assistant_id(&self, assistant_id: &str) -> Result, DbError> { + Ok((self.row.assistant_id == assistant_id).then_some(self.row.clone())) } - async fn get_by_definition_id(&self, definition_id: &str) -> Result, DbError> { - Ok((self.row.definition_id == definition_id).then_some(self.row.clone())) + async fn get_by_id(&self, definition_id: &str) -> Result, DbError> { + Ok((self.row.id == definition_id).then_some(self.row.clone())) } async fn get_by_source_ref( @@ -1269,7 +1269,7 @@ struct SingleAssistantOverlayRepo { #[async_trait::async_trait] impl IAssistantOverlayRepository for SingleAssistantOverlayRepo { async fn get(&self, definition_id: &str) -> Result, DbError> { - Ok((self.row.definition_id == definition_id).then_some(self.row.clone())) + Ok((self.row.assistant_definition_id == definition_id).then_some(self.row.clone())) } async fn list(&self) -> Result, DbError> { @@ -1735,8 +1735,8 @@ fn make_agent_metadata_row(id: &str, backend: &str, icon: &str) -> AgentMetadata fn word_creator_definition() -> AssistantDefinitionRow { AssistantDefinitionRow { - definition_id: "def-word-creator".into(), - assistant_key: "word-creator".into(), + id: "def-word-creator".into(), + assistant_id: "word-creator".into(), source: "builtin".into(), owner_type: "system".into(), source_ref: Some("word-creator".into()), @@ -1918,8 +1918,8 @@ async fn tc_create_team_prefers_assistant_avatar_over_backend_logo() { )])); let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { row: AssistantDefinitionRow { - definition_id: "def-team-lead".into(), - assistant_key: "assistant-lead".into(), + id: "def-team-lead".into(), + assistant_id: "assistant-lead".into(), source: "builtin".into(), owner_type: "system".into(), source_ref: Some("assistant-lead".into()), @@ -1989,8 +1989,8 @@ async fn tc_create_team_carries_assistant_identity_into_lead_conversation_extra( let agent_metadata_repo: Arc = Arc::new(StubAgentMetadataRepo::empty()); let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { row: AssistantDefinitionRow { - definition_id: "def-team-lead".into(), - assistant_key: "assistant-lead".into(), + id: "def-team-lead".into(), + assistant_id: "assistant-lead".into(), source: "user".into(), owner_type: "user".into(), source_ref: None, @@ -2065,8 +2065,8 @@ async fn tc_create_team_derives_backend_from_assistant_when_backend_missing() { let agent_metadata_repo: Arc = Arc::new(StubAgentMetadataRepo::empty()); let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { row: AssistantDefinitionRow { - definition_id: "def-team-lead".into(), - assistant_key: "assistant-lead".into(), + id: "def-team-lead".into(), + assistant_id: "assistant-lead".into(), source: "user".into(), owner_type: "user".into(), source_ref: None, @@ -2101,7 +2101,7 @@ async fn tc_create_team_derives_backend_from_assistant_when_backend_missing() { }); let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { row: AssistantOverlayRow { - definition_id: "def-team-lead".into(), + assistant_definition_id: "def-team-lead".into(), enabled: true, sort_order: 0, agent_id_override: Some("codex".into()), @@ -2152,8 +2152,8 @@ async fn tc_create_team_ignores_requested_backend_when_assistant_id_present() { let agent_metadata_repo: Arc = Arc::new(StubAgentMetadataRepo::empty()); let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { row: AssistantDefinitionRow { - definition_id: "def-team-lead".into(), - assistant_key: "assistant-lead".into(), + id: "def-team-lead".into(), + assistant_id: "assistant-lead".into(), source: "user".into(), owner_type: "user".into(), source_ref: None, @@ -2188,7 +2188,7 @@ async fn tc_create_team_ignores_requested_backend_when_assistant_id_present() { }); let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { row: AssistantOverlayRow { - definition_id: "def-team-lead".into(), + assistant_definition_id: "def-team-lead".into(), enabled: true, sort_order: 0, agent_id_override: Some("codex".into()), @@ -2400,8 +2400,8 @@ async fn ta_add_agent_uses_model_fallback_for_acp_backend() { async fn ta_add_agent_derives_backend_from_assistant_when_backend_missing() { let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { row: AssistantDefinitionRow { - definition_id: "def-team-worker".into(), - assistant_key: "assistant-worker".into(), + id: "def-team-worker".into(), + assistant_id: "assistant-worker".into(), source: "user".into(), owner_type: "user".into(), source_ref: None, @@ -2436,7 +2436,7 @@ async fn ta_add_agent_derives_backend_from_assistant_when_backend_missing() { }); let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { row: AssistantOverlayRow { - definition_id: "def-team-worker".into(), + assistant_definition_id: "def-team-worker".into(), enabled: true, sort_order: 0, agent_id_override: Some("codex".into()), @@ -2495,8 +2495,8 @@ async fn ta_add_agent_derives_backend_from_assistant_when_backend_missing() { async fn ta_add_agent_ignores_requested_backend_when_assistant_id_present() { let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { row: AssistantDefinitionRow { - definition_id: "def-team-worker".into(), - assistant_key: "assistant-worker".into(), + id: "def-team-worker".into(), + assistant_id: "assistant-worker".into(), source: "user".into(), owner_type: "user".into(), source_ref: None, @@ -2531,7 +2531,7 @@ async fn ta_add_agent_ignores_requested_backend_when_assistant_id_present() { }); let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { row: AssistantOverlayRow { - definition_id: "def-team-worker".into(), + assistant_definition_id: "def-team-worker".into(), enabled: true, sort_order: 0, agent_id_override: Some("codex".into()), @@ -3443,8 +3443,8 @@ async fn es1_ensure_session_creates_session() { async fn spawn_agent_in_session_succeeds_without_active_team_run() { let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { row: AssistantDefinitionRow { - definition_id: "def-spawn-worker".into(), - assistant_key: "assistant-worker".into(), + id: "def-spawn-worker".into(), + assistant_id: "assistant-worker".into(), source: "user".into(), owner_type: "user".into(), source_ref: None, @@ -3479,7 +3479,7 @@ async fn spawn_agent_in_session_succeeds_without_active_team_run() { }); let overlay_repo: Arc = Arc::new(SingleAssistantOverlayRepo { row: AssistantOverlayRow { - definition_id: "def-spawn-worker".into(), + assistant_definition_id: "def-spawn-worker".into(), enabled: true, sort_order: 0, agent_id_override: Some("codex".into()), From 1b737a2dbde162a55a0b7f7cbe5d903d19edfb5c Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 23 Jun 2026 20:36:51 +0800 Subject: [PATCH 125/135] chore: apply auto-fixes (fmt + clippy) --- crates/aionui-cron/src/executor.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/aionui-cron/src/executor.rs b/crates/aionui-cron/src/executor.rs index e466b7bdb..5b1a7feb3 100644 --- a/crates/aionui-cron/src/executor.rs +++ b/crates/aionui-cron/src/executor.rs @@ -1011,7 +1011,6 @@ async fn inject_agent_identity( if let Some(backend) = meta.backend { extra.insert("backend".to_owned(), serde_json::Value::String(backend)); } - return; } } From 607a409e24984429c6a9cb05cd04079665ce6210 Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 23 Jun 2026 20:44:30 +0800 Subject: [PATCH 126/135] test(cron): align workspace e2e fixtures with assistant config --- crates/aionui-app/tests/cron_e2e.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/aionui-app/tests/cron_e2e.rs b/crates/aionui-app/tests/cron_e2e.rs index 024b5ef29..d78d601d4 100644 --- a/crates/aionui-app/tests/cron_e2e.rs +++ b/crates/aionui-app/tests/cron_e2e.rs @@ -230,7 +230,6 @@ async fn cj3b_create_accepts_workspace_with_whitespace_segment() { "created_by": "user", "execution_mode": "new_conversation", "agent_config": { - "backend": "acp", "name": "Cron Agent", "assistant_id": DEFAULT_CRON_ASSISTANT_ID, "workspace": workspace.to_string_lossy() @@ -260,7 +259,6 @@ async fn cj3c_create_rejects_missing_workspace_path() { "created_by": "user", "execution_mode": "new_conversation", "agent_config": { - "backend": "claude", "name": "Claude Code", "assistant_id": DEFAULT_CRON_ASSISTANT_ID, "workspace": "/tmp/cron-job-workspace-missing-path" @@ -326,6 +324,7 @@ async fn cj5_get_nonexistent() { async fn cj5b_run_now_legacy_workspace_with_whitespace_succeeds() { let (mut app, services) = build_app_with_mock_agents().await; let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await; + ensure_default_assistant(&mut app, &token, &csrf).await; let cron_repo = SqliteCronRepository::new(services.database.pool().clone()); let now = aionui_common::now_ms(); let dir = std::env::temp_dir().join(format!("aionui-cron-test-{}", aionui_common::generate_short_id())); @@ -346,8 +345,8 @@ async fn cj5b_run_now_legacy_workspace_with_whitespace_succeeds() { execution_mode: "new_conversation".into(), agent_config: Some( json!({ - "backend": "acp", "name": "Cron Agent", + "assistant_id": DEFAULT_CRON_ASSISTANT_ID, "workspace": workspace.to_string_lossy() }) .to_string(), @@ -709,7 +708,6 @@ async fn rn1c_run_now_new_conversation_preset_assistant_uses_fixed_assistant_mcp "created_by": "user", "execution_mode": "new_conversation", "agent_config": { - "backend": "codex", "name": "Cron MCP Assistant", "assistant_id": "u-fixed-mcp" } From 643be6b9467204ba7faeba3bb5ed9af15e1a1657 Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 23 Jun 2026 20:46:49 +0800 Subject: [PATCH 127/135] fix(assistant): include agent id in response projections --- crates/aionui-api-types/src/assistant.rs | 3 +++ crates/aionui-assistant/src/service.rs | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/crates/aionui-api-types/src/assistant.rs b/crates/aionui-api-types/src/assistant.rs index 589103890..4c2d5c5bd 100644 --- a/crates/aionui-api-types/src/assistant.rs +++ b/crates/aionui-api-types/src/assistant.rs @@ -39,6 +39,7 @@ pub struct AssistantResponse { pub avatar: Option, pub enabled: bool, pub sort_order: i32, + pub agent_id: String, pub preset_agent_type: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub enabled_skills: Vec, @@ -90,6 +91,7 @@ pub struct AssistantStateResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AssistantEngineResponse { + pub agent_id: String, pub agent_backend: String, } @@ -336,6 +338,7 @@ mod tests { avatar: None, enabled: true, sort_order: 5, + agent_id: "agent-gemini".into(), preset_agent_type: "gemini".into(), enabled_skills: vec![], custom_skill_names: vec![], diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 19006b602..8c4ffee00 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -1777,6 +1777,7 @@ fn definition_to_response( avatar: avatar_display_value(definition), enabled: state.is_none_or(|row| row.enabled), sort_order: state.map(|row| row.sort_order).unwrap_or(0), + agent_id: projection.agent_id.clone(), preset_agent_type: projection.runtime_backend.clone(), enabled_skills: decode_str_list(Some(definition.default_skill_ids.as_str()))?, custom_skill_names: decode_str_list(Some(definition.custom_skill_names.as_str()))?, @@ -1845,6 +1846,7 @@ fn definition_to_detail_response( last_used_at: state.and_then(|row| row.last_used_at), }, engine: AssistantEngineResponse { + agent_id: projection.agent_id.clone(), agent_backend: projection.runtime_backend.clone(), }, rules: AssistantRulesResponse { @@ -1894,6 +1896,7 @@ fn definition_to_detail_response( #[derive(Debug, Clone)] struct AssistantRuntimeProjection { + agent_id: String, runtime_backend: String, agent_status: AgentManagementStatus, agent_status_message: Option, @@ -1949,6 +1952,9 @@ fn assistant_projection_for_definition( let runtime_backend = agent_row .map(runtime_backend_for_management_row) .unwrap_or_else(|| fallback_runtime_backend.to_owned()); + let agent_id = agent_row + .map(|row| row.id.clone()) + .unwrap_or_else(|| effective_agent_id.to_owned()); let agent_status = agent_row .map(|row| row.status) @@ -1978,6 +1984,7 @@ fn assistant_projection_for_definition( }; AssistantRuntimeProjection { + agent_id, runtime_backend, agent_status, agent_status_message, From f50eb3859d597599ca697402e276d9eac1c61c2d Mon Sep 17 00:00:00 2001 From: zk <> Date: Tue, 23 Jun 2026 20:48:00 +0800 Subject: [PATCH 128/135] chore: apply auto-fixes (fmt + clippy) --- crates/aionui-api-types/src/assistant.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/aionui-api-types/src/assistant.rs b/crates/aionui-api-types/src/assistant.rs index 4c2d5c5bd..2a6933a4c 100644 --- a/crates/aionui-api-types/src/assistant.rs +++ b/crates/aionui-api-types/src/assistant.rs @@ -425,6 +425,7 @@ mod tests { "name": "X", "enabled": true, "sort_order": 7, // snake required field + "agent_id": "agent-gemini", // snake required field "preset_agent_type": "gemini", // snake required field "agent_status": "online", // snake required field "team_selectable": true, // snake required field From 6af8c1d2a907ebbe267413e01dcbd2764ee29c81 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 24 Jun 2026 09:45:31 +0800 Subject: [PATCH 129/135] chore: apply auto-fixes (fmt + clippy) --- .../src/persistence/acp_session_sync.rs | 1 - crates/aionui-api-types/src/assistant.rs | 40 ++- crates/aionui-api-types/src/cron.rs | 13 +- crates/aionui-api-types/src/lib.rs | 2 +- .../assets/builtin-assistants/assistants.json | 42 +-- crates/aionui-app/src/router/state.rs | 2 +- crates/aionui-app/tests/assistants_e2e.rs | 104 +++++- crates/aionui-app/tests/conversation_e2e.rs | 2 +- crates/aionui-app/tests/cron_e2e.rs | 4 +- crates/aionui-app/tests/extension_e2e.rs | 8 +- crates/aionui-app/tests/team_e2e.rs | 2 +- crates/aionui-assistant/src/builtin.rs | 12 +- crates/aionui-assistant/src/service.rs | 309 ++++++++++-------- .../tests/message_service_integration.rs | 3 +- crates/aionui-conversation/src/service.rs | 1 - .../aionui-conversation/src/service_test.rs | 5 +- .../src/session_context.rs | 12 +- crates/aionui-cron/src/executor.rs | 4 - crates/aionui-cron/src/service.rs | 5 - crates/aionui-cron/src/types.rs | 12 - .../aionui-cron/tests/service_integration.rs | 2 - .../013_agent_connection_snapshot.sql | 112 ++++++- crates/aionui-db/src/models/acp_session.rs | 1 - crates/aionui-db/src/models/assistant.rs | 11 - .../aionui-db/src/repository/acp_session.rs | 1 - .../src/repository/sqlite_acp_session.rs | 8 +- .../src/repository/sqlite_assistant.rs | 78 +---- .../tests/cron_assistant_first_migration.rs | 21 ++ crates/aionui-extension/src/constants.rs | 2 +- crates/aionui-extension/src/manifest.rs | 2 +- .../src/resolvers/assistant.rs | 14 +- crates/aionui-extension/src/routes.rs | 4 +- crates/aionui-extension/src/types.rs | 6 +- .../tests/contribution_resolution_test.rs | 4 +- 34 files changed, 496 insertions(+), 353 deletions(-) diff --git a/crates/aionui-ai-agent/src/persistence/acp_session_sync.rs b/crates/aionui-ai-agent/src/persistence/acp_session_sync.rs index 9efc8e9d3..be58f3f16 100644 --- a/crates/aionui-ai-agent/src/persistence/acp_session_sync.rs +++ b/crates/aionui-ai-agent/src/persistence/acp_session_sync.rs @@ -277,7 +277,6 @@ mod tests { let repo: Arc = Arc::new(SqliteAcpSessionRepository::new(db.pool().clone())); repo.create(&CreateAcpSessionParams { conversation_id: "conv-1", - agent_backend: "claude", agent_source: "builtin", agent_id: "2d23ff1c", }) diff --git a/crates/aionui-api-types/src/assistant.rs b/crates/aionui-api-types/src/assistant.rs index 2a6933a4c..b822ecf4b 100644 --- a/crates/aionui-api-types/src/assistant.rs +++ b/crates/aionui-api-types/src/assistant.rs @@ -7,7 +7,9 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use crate::AgentManagementStatus; +use aionui_common::AgentType; + +use crate::{AgentManagementStatus, AgentSource}; // --------------------------------------------------------------------------- // Response + source enum @@ -22,6 +24,16 @@ pub enum AssistantSource { User, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssistantAgentResponse { + pub id: String, + #[serde(rename = "type")] + pub r#type: AgentType, + pub source: AgentSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend: Option, +} + /// Wire shape returned by `GET /api/assistants` (single element) and /// by the single-resource CRUD handlers. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -40,7 +52,8 @@ pub struct AssistantResponse { pub enabled: bool, pub sort_order: i32, pub agent_id: String, - pub preset_agent_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub enabled_skills: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -92,7 +105,8 @@ pub struct AssistantStateResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AssistantEngineResponse { pub agent_id: String, - pub agent_backend: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -217,7 +231,7 @@ pub struct CreateAssistantRequest { #[serde(default)] pub avatar: Option, #[serde(default)] - pub preset_agent_type: Option, + pub agent_id: Option, #[serde(default)] pub enabled_skills: Option>, #[serde(default)] @@ -252,7 +266,7 @@ pub struct UpdateAssistantRequest { #[serde(default)] pub avatar: Option, #[serde(default)] - pub preset_agent_type: Option, + pub agent_id: Option, #[serde(default)] pub enabled_skills: Option>, #[serde(default)] @@ -339,7 +353,7 @@ mod tests { enabled: true, sort_order: 5, agent_id: "agent-gemini".into(), - preset_agent_type: "gemini".into(), + agent: None, enabled_skills: vec![], custom_skill_names: vec![], disabled_builtin_skills: vec![], @@ -357,7 +371,8 @@ mod tests { }; let json = serde_json::to_value(&resp).unwrap(); - assert_eq!(json["preset_agent_type"], "gemini"); + assert!(json.get("preset_agent_type").is_none()); + assert_eq!(json["agent_id"], "agent-gemini"); assert_eq!(json["sort_order"], 5); assert_eq!(json["last_used_at"], 1234); } @@ -368,7 +383,7 @@ mod tests { let req: CreateAssistantRequest = serde_json::from_value(json).unwrap(); assert_eq!(req.name, "X"); assert!(req.id.is_none()); - assert!(req.preset_agent_type.is_none()); + assert!(req.agent_id.is_none()); assert!(req.defaults.is_none()); } @@ -426,20 +441,17 @@ mod tests { "enabled": true, "sort_order": 7, // snake required field "agent_id": "agent-gemini", // snake required field - "preset_agent_type": "gemini", // snake required field "agent_status": "online", // snake required field "team_selectable": true, // snake required field "deletable": true, // snake required field - "presetAgentType": "claude", // legacy camel — must be ignored + "agentId": "agent-claude", // legacy camel — must be ignored + "agentId": "agent-claude", // legacy camel — must be ignored "sortOrder": 99, // legacy camel — must be ignored "lastUsedAt": 111_222, // legacy camel for optional field — must be ignored }); let resp: AssistantResponse = serde_json::from_value(json).unwrap(); // If camel were aliased, these would be the camel values. - assert_eq!( - resp.preset_agent_type, "gemini", - "snake_case preset_agent_type must win" - ); + assert_eq!(resp.agent_id, "agent-gemini", "snake_case agent_id must win"); assert_eq!(resp.sort_order, 7, "snake_case sort_order must win"); assert!( resp.last_used_at.is_none(), diff --git a/crates/aionui-api-types/src/cron.rs b/crates/aionui-api-types/src/cron.rs index 2efaa3e91..31c941f63 100644 --- a/crates/aionui-api-types/src/cron.rs +++ b/crates/aionui-api-types/src/cron.rs @@ -48,8 +48,6 @@ pub struct CronAgentConfigReadDto { #[serde(default, skip_serializing_if = "Option::is_none")] pub custom_agent_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub preset_agent_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, @@ -244,17 +242,16 @@ mod write_tests { } #[test] - fn cron_agent_config_write_rejects_legacy_preset_flags() { + fn cron_agent_config_write_rejects_legacy_preset_flag() { let err = serde_json::from_value::(serde_json::json!({ "name": "Helper", "assistant_id": "assistant-1", "is_preset": true, - "preset_agent_type": "claude", })) - .expect_err("legacy preset fields must be rejected"); + .expect_err("legacy preset flag must be rejected"); let message = err.to_string(); - assert!(message.contains("is_preset") || message.contains("preset_agent_type")); + assert!(message.contains("is_preset")); } #[test] @@ -452,7 +449,6 @@ mod tests { "is_preset": true, "assistant_id": "assistant-1", "custom_agent_id": "agent-1", - "preset_agent_type": "claude", "mode": "auto", "model_id": "claude-sonnet-4-6", "model": {"provider_id": "provider-1", "model": "claude-sonnet-4-6"}, @@ -491,7 +487,6 @@ mod tests { is_preset: None, assistant_id: None, custom_agent_id: None, - preset_agent_type: None, mode: None, model_id: None, model: None, @@ -512,7 +507,6 @@ mod tests { is_preset: Some(false), assistant_id: Some("assistant-1".into()), custom_agent_id: Some("c1".into()), - preset_agent_type: None, mode: Some("plan".into()), model_id: Some("m1".into()), model: Some(ProviderWithModel { @@ -560,7 +554,6 @@ mod tests { is_preset: None, assistant_id: None, custom_agent_id: None, - preset_agent_type: None, mode: None, model_id: None, model: None, diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index b352fe470..10f03f8c0 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -51,7 +51,7 @@ pub use agent_error::{ AgentStreamErrorData, }; pub use assistant::{ - AssistantCapabilitiesResponse, AssistantDefaultListRequest, AssistantDefaultListResponse, + AssistantAgentResponse, AssistantCapabilitiesResponse, AssistantDefaultListRequest, AssistantDefaultListResponse, AssistantDefaultScalarRequest, AssistantDefaultScalarResponse, AssistantDefaultsRequest, AssistantDefaultsResponse, AssistantDetailResponse, AssistantEngineResponse, AssistantPreferencesResponse, AssistantProfileResponse, AssistantPromptsResponse, AssistantResponse, AssistantRulesResponse, AssistantSource, AssistantStateResponse, diff --git a/crates/aionui-app/assets/builtin-assistants/assistants.json b/crates/aionui-app/assets/builtin-assistants/assistants.json index 7588d8ad5..c6936ab19 100644 --- a/crates/aionui-app/assets/builtin-assistants/assistants.json +++ b/crates/aionui-app/assets/builtin-assistants/assistants.json @@ -18,7 +18,7 @@ "uk-UA": "Створюйте, редагуйте та аналізуйте професійні документи Word за допомогою officecli: звіти, пропозиції, листи, нотатки тощо." }, "avatar": "avatars/word-creator.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "officecli-docx" ], @@ -71,7 +71,7 @@ "uk-UA": "Створюйте, редагуйте та аналізуйте професійні презентації PowerPoint за допомогою officecli: виразний дизайн, різноманітні макети та візуальний вплив." }, "avatar": "avatars/ppt-creator.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "officecli-pptx" ], @@ -124,7 +124,7 @@ "uk-UA": "Створюйте, редагуйте та аналізуйте професійні таблиці Excel за допомогою officecli: фінансові моделі, дашборди, трекери та аналіз даних." }, "avatar": "avatars/excel-creator.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "officecli-xlsx" ], @@ -177,7 +177,7 @@ "uk-UA": "Створюйте професійні презентації з анімацією Morph за допомогою officecli. Підтримує різні візуальні стилі та повний робочий процес від ідеї до готових слайдів." }, "avatar": "avatars/morph-ppt.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "morph-ppt" ], @@ -226,7 +226,7 @@ "zh-CN": "把 GLB 3D 模型变成电影感 Morph 演示文稿。模型是视觉主角——特写看细节、俯视看结构、仰拍看气势,每页之间用 Morph 转场做流畅的镜头运动。注意:3D 模型和 Morph 转场效果需要在微软 PowerPoint 中打开才能正常显示。" }, "avatar": "avatars/morph-ppt-3d.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "morph-ppt-3d", "morph-ppt" @@ -270,7 +270,7 @@ "uk-UA": "Створюйте інвесторські пітч-деки, презентації продуктів та корпоративні продажі: градієнтний дизайн, графіки, таблиці конкурентів, слайди команди та нотатки доповідача. Підходить для стадій від Seed до Series A+." }, "avatar": "avatars/pitch-deck-creator.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "officecli-pitch-deck" ], @@ -323,7 +323,7 @@ "uk-UA": "Перетворюйте CSV або табличні дані на професійні дашборди Excel: KPI-картки, графіки з прив'язкою до даних, спарклайни та умовне форматування. Автоматично масштабує складність під обсяг даних." }, "avatar": "avatars/dashboard-creator.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "officecli-data-dashboard" ], @@ -376,7 +376,7 @@ "uk-UA": "Створюйте структуровані академічні статті, наукові роботи та White Papers: зміст Word, формули LaTeX в OMML, бібліографія (APA/Physics/Chicago), виноски та багатоколонкова верстка." }, "avatar": "avatars/academic-paper.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "officecli-academic-paper" ], @@ -429,7 +429,7 @@ "uk-UA": "Будуйте фінансові моделі на основі формул за текстовим запитом: три фінансові форми, DCF-оцінка, Cap Tables, сценарний аналіз та графіки боргів." }, "avatar": "avatars/financial-model-creator.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "officecli-financial-model" ], @@ -482,7 +482,7 @@ "uk-UA": "Експертний посібник зі встановлення, розгортання, налаштування та усунення несправностей OpenClaw. Допомагає з налаштуванням та безпекою." }, "avatar": "avatars/openclaw-setup.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "openclaw-setup", "aionui-webui-setup" @@ -536,7 +536,7 @@ "uk-UA": "Автономне виконання завдань з роботою з файлами, обробкою документів та багатокроковим плануванням робочих процесів." }, "avatar": "avatars/cowork.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "skill-creator", "officecli-pptx", @@ -593,7 +593,7 @@ "uk-UA": "Генеруйте повноцінну 3D-гру-платформер зі збором предметів в одному HTML-файлі." }, "avatar": "avatars/game-3d.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [], "custom_skill_names": [], "disabled_builtin_skills": [], @@ -644,7 +644,7 @@ "uk-UA": "Професійний UI/UX-помічник з 57 стилями, 95 кольоровими палітрами, 56 поєднаннями шрифтів та найкращими практиками для різних технологій." }, "avatar": "avatars/ui-ux-pro-max.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [], "custom_skill_names": [], "disabled_builtin_skills": [], @@ -695,7 +695,7 @@ "uk-UA": "Файлове планування в стилі Manus для складних завдань. Використовує task_plan.md, findings.md та progress.md для збереження контексту." }, "avatar": "avatars/planning-with-files.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [], "custom_skill_names": [], "disabled_builtin_skills": [], @@ -746,7 +746,7 @@ "uk-UA": "Коуч з особистого розвитку на основі фреймворка HUMAN 3.0: 4 квадранти (Розум/Тіло/Дух/Призвання), 3 рівні та 3 фази росту." }, "avatar": "avatars/human-3-coach.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [], "custom_skill_names": [], "disabled_builtin_skills": [], @@ -797,7 +797,7 @@ "uk-UA": "Розгортає запит на найм у повноцінний опис вакансії та зображення, а потім публікує це в соцмережах через конектори." }, "avatar": "avatars/social-job-publisher.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "xiaohongshu-recruiter", "x-recruiter" @@ -851,7 +851,7 @@ "uk-UA": "Соціальна мережа для AI-агентів. Публікації, коментарі, голосування та створення спільнот." }, "avatar": "avatars/moltbook.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "moltbook" ], @@ -904,7 +904,7 @@ "uk-UA": "Створюйте блок-схеми, діаграми послідовності, станів, класів та ER-діаграми з красивими темами оформлення." }, "avatar": "avatars/beautiful-mermaid.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "mermaid" ], @@ -957,7 +957,7 @@ "uk-UA": "Іммерсивний сюжетний рольовий режим. Можна почати трьома способами: описати персонажів словами, вставити PNG-зображення або відкрити папку з картками персонажів та лором світу." }, "avatar": "avatars/story-roleplay.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "story-roleplay" ], @@ -1010,7 +1010,7 @@ "uk-UA": "Створюйте заповнювані форми Word (.docx) зі справжніми елементами керування вмістом, полями-прапорцями, MERGEFIELD для злиття пошти та захистом документа — редагуються лише задані поля, решта залишається заблокованою. HR-анкети, опитування, шаблони контрактів / SOW, комплаєнс-чеклисти, медичні анкети." }, "avatar": "📋", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "officecli-word-form" ], @@ -1063,7 +1063,7 @@ "uk-UA": "Ваш універсальний дворецький AionUi: налаштування асистентів, навичок, серверів MCP і провайдерів LLM; налаштування віддаленого доступу, щоб відкривати AionUi з телефона або ділитися посиланням; і діагностика проблем — зависань розмов, збоїв моделей або чому не виконалося заплановане завдання." }, "avatar": "avatars/aionui-assistant.jpg", - "preset_agent_type": "aionrs", + "agent_ref": "aionrs", "enabled_skills": [ "aionui-config", "aionui-troubleshooting", diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index ebf8b21bd..f5eecc4f9 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -318,7 +318,7 @@ pub fn build_assistant_state(services: &AppServices) -> AssistantRouterState { let override_repo: Arc = Arc::new(SqliteAssistantOverrideRepository::new(pool.clone())); // Used by `AssistantService::resolve_default_agent_type` to infer a - // working `preset_agent_type` from the configured provider list when + // working `agent_id` from the configured provider list when // the caller does not supply one (ELECTRON-1J1 / 1KV). let provider_repo: Arc = Arc::new(SqliteProviderRepository::new(pool.clone())); let builtin = Arc::new(BuiltinAssistantRegistry::load()); diff --git a/crates/aionui-app/tests/assistants_e2e.rs b/crates/aionui-app/tests/assistants_e2e.rs index 8aa47a4da..6e5a850cc 100644 --- a/crates/aionui-app/tests/assistants_e2e.rs +++ b/crates/aionui-app/tests/assistants_e2e.rs @@ -12,8 +12,13 @@ mod common; use std::sync::Arc; +use aionui_api_types::{ + AgentManagementRow, AgentManagementStatus, AgentSnapshotCheckKind, AgentSnapshotCheckStatus, AgentSource, + AgentSourceInfo, BehaviorPolicy, +}; use aionui_app::{AppConfig, AppServices, ModuleStates, build_module_states, create_router_with_states}; -use aionui_assistant::{AssistantRouterState, AssistantService, BuiltinAssistantRegistry}; +use aionui_assistant::{AssistantAgentCatalogPort, AssistantRouterState, AssistantService, BuiltinAssistantRegistry}; +use aionui_common::AgentType; use aionui_db::{ IAssistantDefinitionRepository, IAssistantOverlayRepository, IAssistantOverrideRepository, IAssistantPreferenceRepository, IAssistantRepository, IProviderRepository, SqliteAssistantDefinitionRepository, @@ -53,11 +58,67 @@ struct Fixture { _ext_tmp: TempDir, } +#[derive(Clone)] +struct TestAgentCatalog { + rows: Vec, +} + +#[async_trait::async_trait] +impl AssistantAgentCatalogPort for TestAgentCatalog { + async fn list_management_agents(&self) -> Result, aionui_assistant::AssistantError> { + Ok(self.rows.clone()) + } +} + +fn test_agent_row(id: &str, backend: Option<&str>, agent_type: AgentType, name: &str) -> AgentManagementRow { + AgentManagementRow { + id: id.to_owned(), + icon: None, + name: name.to_owned(), + name_i18n: None, + description: None, + description_i18n: None, + backend: backend.map(str::to_owned), + agent_type, + agent_source: match agent_type { + AgentType::Aionrs => AgentSource::Internal, + _ => AgentSource::Builtin, + }, + agent_source_info: AgentSourceInfo::default(), + enabled: true, + installed: true, + command: backend.map(str::to_owned), + args: Vec::new(), + env: Vec::new(), + native_skills_dirs: None, + behavior_policy: BehaviorPolicy { + supports_team: true, + ..Default::default() + }, + yolo_id: None, + sort_order: 0, + team_capable: true, + status: AgentManagementStatus::Online, + last_check_status: Some(AgentSnapshotCheckStatus::Online), + last_check_kind: Some(AgentSnapshotCheckKind::Manual), + last_check_error_code: None, + last_check_error_message: None, + last_check_error_details: None, + last_check_guidance: None, + last_check_latency_ms: None, + last_check_at: None, + last_success_at: None, + last_failure_at: None, + has_command_override: false, + env_override_key_count: 0, + } +} + async fn insert_generated_bare_assistant( fx: &Fixture, assistant_id: &str, source_ref: &str, - backend: &str, + _backend: &str, name: &str, ) { let pool = fx.services.database.pool().clone(); @@ -79,7 +140,7 @@ async fn insert_generated_bare_assistant( description_i18n: "{}", avatar_type: "none", avatar_value: None, - agent_id: backend, + agent_id: source_ref, rule_resource_type: "none", rule_resource_ref: None, rule_inline_content: None, @@ -140,14 +201,14 @@ async fn fixture() -> Fixture { { "id": "builtin-office", "name": "Office", - "preset_agent_type": "gemini", + "agent_ref": "codex", "rule_file": "rules/office.{locale}.md", "avatar": "office.png", }, { "id": "builtin-bare", "name": "Bare", - "preset_agent_type": "gemini", + "agent_ref": "codex", } ] }); @@ -258,7 +319,7 @@ async fn fixture() -> Fixture { Arc::new(SqliteAssistantOverrideRepository::new(pool.clone())); let provider_repo: Arc = Arc::new(SqliteProviderRepository::new(pool.clone())); // Seed an OpenAI-compatible provider so create / import calls without - // an explicit `preset_agent_type` resolve to `"aionrs"` instead of + // an explicit `agent_id` resolve to `"aionrs"` instead of // erroring out — mirroring a configured production setup. provider_repo .create(aionui_db::CreateProviderParams { @@ -290,7 +351,13 @@ async fn fixture() -> Fixture { override_repo, provider_repo, builtin, - agent_catalog: None, + agent_catalog: Some(Arc::new(TestAgentCatalog { + rows: vec![ + test_agent_row("8e1acf31", Some("codex"), AgentType::Acp, "Codex CLI"), + test_agent_row("cc126dd5", Some("gemini"), AgentType::Acp, "Gemini CLI"), + test_agent_row("632f31d2", None, AgentType::Aionrs, "Aion CLI"), + ], + })), }, user_data_dir.clone(), )); @@ -339,12 +406,16 @@ async fn list_populated_excludes_extension_assistants() { let list = json["data"].as_array().unwrap(); // Extension-contributed assistants are no longer part of the unified // assistant catalog. - assert_eq!(list.len(), 2, "body = {json}"); + assert_eq!(list.len(), 5, "body = {json}"); let ids: Vec<&str> = list.iter().map(|a| a["id"].as_str().unwrap()).collect(); + assert!(ids.contains(&"bare:8e1acf31")); + assert!(ids.contains(&"bare:cc126dd5")); + assert!(ids.contains(&"bare:632f31d2")); assert!(ids.contains(&"builtin-office")); assert!(ids.contains(&"builtin-bare")); assert!(!ids.contains(&"ext-helper")); let sources: Vec<&str> = list.iter().map(|a| a["source"].as_str().unwrap()).collect(); + assert!(sources.contains(&"bare")); assert!(sources.contains(&"builtin")); assert!(!sources.contains(&"extension")); } @@ -428,7 +499,7 @@ async fn get_detail_returns_definition_state_preferences_and_rules() { "id": "u1", "name": "Mine", "description": "hello", - "preset_agent_type": "aionrs", + "agent_id": "632f31d2", "enabled_skills": ["legacy-default"], "custom_skill_names": ["custom-note"], "disabled_builtin_skills": ["todo-tracker"], @@ -527,7 +598,9 @@ async fn get_detail_returns_definition_state_preferences_and_rules() { assert_eq!(data["profile"]["name"], "Mine"); assert_eq!(data["state"]["enabled"], false); assert_eq!(data["state"]["sort_order"], 7); - assert_eq!(data["engine"]["agent_backend"], "codex"); + assert_eq!(data["engine"]["agent_id"], "8e1acf31"); + assert_eq!(data["engine"]["agent"]["backend"], "codex"); + assert_eq!(data["engine"]["agent"]["type"], "acp"); assert_eq!(data["rules"]["content"], "user rule body"); assert_eq!(data["rules"]["storage_mode"], "user_file"); assert_eq!(data["defaults"]["model"]["mode"], "fixed"); @@ -566,7 +639,8 @@ async fn get_detail_generated_assistant_exposes_bare_runtime_fields() { data["team_block_reason"], "This assistant's agent could not be resolved." ); - assert_eq!(data["engine"]["agent_backend"], "droid"); + assert_eq!(data["engine"]["agent_id"], "agent-droid"); + assert_eq!(data["engine"]["agent"], Value::Null); } // =========================================================================== @@ -641,7 +715,7 @@ async fn create_user_avatar_from_local_file_is_served_via_assistant_avatar_route "id": "u-avatar", "name": "Avatar User", "avatar": source_avatar.to_string_lossy(), - "preset_agent_type": "aionrs", + "agent_id": "632f31d2", }), &fx.token, &fx.csrf, @@ -688,7 +762,7 @@ async fn create_user_avatar_from_builtin_avatar_route_copies_builtin_asset() { "id": "u-avatar-from-builtin", "name": "Builtin Avatar Copy", "avatar": "/api/assistants/builtin-office/avatar", - "preset_agent_type": "aionrs", + "agent_id": "632f31d2", }), &fx.token, &fx.csrf, @@ -738,7 +812,7 @@ async fn create_user_avatar_from_absolute_builtin_avatar_route_copies_builtin_as "id": "u-avatar-from-builtin-absolute", "name": "Builtin Avatar Absolute Copy", "avatar": "http://127.0.0.1:56663/api/assistants/builtin-office/avatar", - "preset_agent_type": "aionrs", + "agent_id": "632f31d2", }), &fx.token, &fx.csrf, @@ -775,7 +849,7 @@ async fn update_user_avatar_with_existing_route_preserves_served_file() { "id": "u-avatar-stable", "name": "Avatar User", "avatar": source_avatar.to_string_lossy(), - "preset_agent_type": "aionrs", + "agent_id": "632f31d2", }), &fx.token, &fx.csrf, diff --git a/crates/aionui-app/tests/conversation_e2e.rs b/crates/aionui-app/tests/conversation_e2e.rs index 29d8b5f58..19f48a505 100644 --- a/crates/aionui-app/tests/conversation_e2e.rs +++ b/crates/aionui-app/tests/conversation_e2e.rs @@ -123,7 +123,7 @@ async fn t1_3b_create_persists_assistant_snapshot_and_updates_preferences() { json!({ "id": "u1", "name": "Snapshot Assistant", - "preset_agent_type": "codex" + "agent_id": "8e1acf31" }), &token, &csrf, diff --git a/crates/aionui-app/tests/cron_e2e.rs b/crates/aionui-app/tests/cron_e2e.rs index d78d601d4..cb7960619 100644 --- a/crates/aionui-app/tests/cron_e2e.rs +++ b/crates/aionui-app/tests/cron_e2e.rs @@ -73,7 +73,7 @@ async fn ensure_default_assistant(app: &mut axum::Router, token: &str, csrf: &st json!({ "id": DEFAULT_CRON_ASSISTANT_ID, "name": "Cron E2E Assistant", - "preset_agent_type": "claude" + "agent_id": "2d23ff1c" }), token, csrf, @@ -683,7 +683,7 @@ async fn rn1c_run_now_new_conversation_preset_assistant_uses_fixed_assistant_mcp json!({ "id": "u-fixed-mcp", "name": "Cron MCP Assistant", - "preset_agent_type": "codex", + "agent_id": "8e1acf31", "defaults": { "mcps": { "mode": "fixed", diff --git a/crates/aionui-app/tests/extension_e2e.rs b/crates/aionui-app/tests/extension_e2e.rs index 7b721b9bd..2d1fdbedd 100644 --- a/crates/aionui-app/tests/extension_e2e.rs +++ b/crates/aionui-app/tests/extension_e2e.rs @@ -100,7 +100,7 @@ fn write_legacy_extension_fixture(tmp: &TempDir) -> std::path::PathBuf { "id": "legacy-assistant", "name": "Legacy Assistant", "avatar": "assets/assistant.png", - "presetAgentType": "gemini", + "agentId": "cc126dd5", "contextFile": "assistants/context.md", "models": ["gemini-2.0-flash"], "enabledSkills": ["review-skill"], @@ -112,7 +112,7 @@ fn write_legacy_extension_fixture(tmp: &TempDir) -> std::path::PathBuf { "id": "legacy-agent", "name": "Legacy Agent", "avatar": "assets/agent.png", - "presetAgentType": "codex", + "agentType": "codex", "contextFile": "agents/context.md", "models": ["codex-mini"], "enabledSkills": ["review-skill"], @@ -475,7 +475,7 @@ async fn eq16_legacy_assistant_agent_and_theme_endpoints_preserve_contract() { let assistants = assistant_json["data"].as_array().unwrap(); assert_eq!(assistants.len(), 1); assert_eq!(assistants[0]["id"], "ext-legacy-assistant"); - assert_eq!(assistants[0]["presetAgentType"], "gemini"); + assert_eq!(assistants[0]["agentId"], "cc126dd5"); assert_eq!(assistants[0]["enabledSkills"][0], "review-skill"); assert_eq!(assistants[0]["prompts"][0], "Review the diff"); assert_eq!(assistants[0]["models"][0], "gemini-2.0-flash"); @@ -496,7 +496,7 @@ async fn eq16_legacy_assistant_agent_and_theme_endpoints_preserve_contract() { let agents = agent_json["data"].as_array().unwrap(); assert_eq!(agents.len(), 1); assert_eq!(agents[0]["id"], "ext-legacy-agent"); - assert_eq!(agents[0]["presetAgentType"], "codex"); + assert_eq!(agents[0]["agentType"], "codex"); assert_eq!(agents[0]["enabledSkills"][0], "review-skill"); assert_eq!(agents[0]["prompts"][0], "Ship it"); assert_eq!(agents[0]["models"][0], "codex-mini"); diff --git a/crates/aionui-app/tests/team_e2e.rs b/crates/aionui-app/tests/team_e2e.rs index c209c268f..0d3123623 100644 --- a/crates/aionui-app/tests/team_e2e.rs +++ b/crates/aionui-app/tests/team_e2e.rs @@ -38,7 +38,7 @@ async fn ensure_default_team_assistant(app: &mut axum::Router, token: &str, csrf json!({ "id": DEFAULT_TEAM_ASSISTANT_ID, "name": "Team E2E Assistant", - "preset_agent_type": "claude" + "agent_id": "2d23ff1c" }), token, csrf, diff --git a/crates/aionui-assistant/src/builtin.rs b/crates/aionui-assistant/src/builtin.rs index 0c190ba62..94a43accd 100644 --- a/crates/aionui-assistant/src/builtin.rs +++ b/crates/aionui-assistant/src/builtin.rs @@ -42,7 +42,7 @@ pub struct BuiltinAssistant { pub description_i18n: HashMap, #[serde(default)] pub avatar: Option, - pub preset_agent_type: String, + pub agent_ref: String, #[serde(default)] pub enabled_skills: Vec, #[serde(default)] @@ -348,7 +348,7 @@ mod tests { "assistants": [{ "id": "legacy", "name": "Legacy", - "preset_agent_type": "gemini", + "agent_ref": "gemini", "skill_file": "skills/legacy.en-US.md" }] }"#, @@ -370,7 +370,7 @@ mod tests { "assistants": [{ "id": "builtin-office", "name": "Office", - "preset_agent_type": "gemini", + "agent_ref": "gemini", "rule_file": "rules/office.{locale}.md" }] }"#, @@ -395,7 +395,7 @@ mod tests { "assistants": [{ "id": "x", "name": "X", - "preset_agent_type": "gemini", + "agent_ref": "gemini", "rule_file": "rules/x.{locale}.md" }] }"#, @@ -413,7 +413,7 @@ mod tests { let tmp = TempDir::new().unwrap(); write_manifest( tmp.path(), - r#"{"assistants":[{"id":"env-only","name":"E","preset_agent_type":"gemini"}]}"#, + r#"{"assistants":[{"id":"env-only","name":"E","agent_ref": "gemini"}]}"#, ); // SAFETY: env-var mutation is only unsafe if another thread reads // environment concurrently. This test is self-contained. @@ -467,7 +467,7 @@ mod tests { r#"{"assistants":[{ "id": "with-file-avatar", "name": "F", - "preset_agent_type": "gemini", + "agent_ref": "gemini", "avatar": "duck.svg" }]}"#, ); diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 8c4ffee00..a69ab7b92 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -6,12 +6,12 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use aionui_api_types::{ - AgentManagementRow, AgentManagementStatus, AgentSource, AssistantCapabilitiesResponse, AssistantDefaultListRequest, - AssistantDefaultListResponse, AssistantDefaultScalarRequest, AssistantDefaultScalarResponse, - AssistantDefaultsRequest, AssistantDefaultsResponse, AssistantDetailResponse, AssistantEngineResponse, - AssistantPreferencesResponse, AssistantProfileResponse, AssistantPromptsResponse, AssistantResponse, - AssistantRulesResponse, AssistantSource, AssistantStateResponse, CreateAssistantRequest, ImportAssistantsRequest, - ImportAssistantsResult, ImportError, SetAssistantStateRequest, UpdateAssistantRequest, + AgentManagementRow, AgentManagementStatus, AgentSource, AssistantAgentResponse, AssistantCapabilitiesResponse, + AssistantDefaultListRequest, AssistantDefaultListResponse, AssistantDefaultScalarRequest, + AssistantDefaultScalarResponse, AssistantDefaultsRequest, AssistantDefaultsResponse, AssistantDetailResponse, + AssistantEngineResponse, AssistantPreferencesResponse, AssistantProfileResponse, AssistantPromptsResponse, + AssistantResponse, AssistantRulesResponse, AssistantSource, AssistantStateResponse, CreateAssistantRequest, + ImportAssistantsRequest, ImportAssistantsResult, ImportError, SetAssistantStateRequest, UpdateAssistantRequest, }; use aionui_common::{generate_prefixed_id, now_ms}; use aionui_db::{ @@ -39,8 +39,8 @@ pub struct AssistantService { preference_repo: Arc, repo: Arc, override_repo: Arc, - /// Used to infer a sane `preset_agent_type` default when the caller did - /// not supply one. The historical default of `"gemini"` 400'd within + /// Used to infer a sane `agent_id` default when the caller did not supply + /// one. The historical default of `"gemini"` 400'd within /// 1 ms on machines without the Gemini CLI (ELECTRON-1J1 / 1KV); we now /// pick an agent that actually matches the configured provider list. provider_repo: Arc, @@ -136,9 +136,7 @@ impl AssistantService { let (definition_id, assistant_id) = self .resolve_definition_identity("builtin", Some(&builtin.id), &builtin.id) .await?; - let agent_id = self - .resolve_agent_id_for_runtime_backend(&builtin.preset_agent_type) - .await?; + let agent_id = self.resolve_agent_id_for_agent_ref(&builtin.agent_ref).await?; self.definition_repo .upsert(&UpsertAssistantDefinitionParams { @@ -222,7 +220,7 @@ impl AssistantService { if self.builtin.has(&row.id) { continue; } - self.upsert_definition_from_legacy_user_row(&row).await?; + self.upsert_definition_from_legacy_user_row(&row, None).await?; } Ok(()) } @@ -241,17 +239,12 @@ impl AssistantService { continue; }; - let agent_id_override = match override_row.preset_agent_type.as_deref() { - Some(value) => Some(self.resolve_agent_id_for_runtime_backend(value).await?), - None => None, - }; - self.state_repo .upsert(&UpsertAssistantOverlayParams { assistant_definition_id: &definition.id, enabled: override_row.enabled, sort_order: override_row.sort_order, - agent_id_override: agent_id_override.as_deref(), + agent_id_override: None, last_used_at: override_row.last_used_at, }) .await @@ -372,7 +365,11 @@ impl AssistantService { Ok(rows) } - async fn upsert_definition_from_legacy_user_row(&self, row: &AssistantRow) -> Result<(), AssistantError> { + async fn upsert_definition_from_legacy_user_row( + &self, + row: &AssistantRow, + requested_agent_id: Option<&str>, + ) -> Result<(), AssistantError> { // User-defined assistants do not expose locale-aware editing in the // current product. Keep the unified definition canonical fields as the // single source of truth and leave *_i18n empty for user rows. @@ -386,9 +383,15 @@ impl AssistantService { normalize_json_array_string(row.disabled_builtin_skills.as_deref(), "disabled_builtin_skills")?; let (avatar_type, avatar_value) = serialize_avatar("user", row.avatar.as_deref()); let (definition_id, assistant_id) = self.resolve_definition_identity("user", Some(&row.id), &row.id).await?; - let agent_id = self - .resolve_agent_id_for_runtime_backend(&row.preset_agent_type) - .await?; + let existing_definition = self.definition_repo.get_by_assistant_id(&assistant_id).await?; + let agent_id = match requested_agent_id { + Some(agent_id) => agent_id.to_string(), + None => match existing_definition { + Some(definition) => definition.agent_id, + None => self.resolve_default_agent_id().await?, + }, + }; + self.resolve_runtime_backend_for_agent_id(&agent_id).await?; self.definition_repo .upsert(&UpsertAssistantDefinitionParams { @@ -670,8 +673,8 @@ impl AssistantService { // Default-agent inference // ----------------------------------------------------------------------- - /// Pick a sane `preset_agent_type` default for newly created / - /// imported assistants when the caller did not supply one. + /// Pick a sane `agent_id` default for newly created / imported assistants + /// when the caller did not supply one. /// /// Inference rule (ELECTRON-1J1 / 1KV): /// 1. If any enabled provider exists (Anthropic, OpenAI, custom, @@ -679,14 +682,14 @@ impl AssistantService { /// OpenAI-compatible and Anthropic-protocol APIs over the /// user-configured base URL and does not require any third-party /// CLI to be installed. CLI-based agents (`claude`, `gemini`) - /// must be opted into explicitly via `preset_agent_type` because + /// must be opted into explicitly via `agent_id` because /// the presence of an Anthropic API key does not imply that the /// Claude Code CLI is on `PATH`. /// 2. Otherwise (no providers configured), return a `BadRequest` /// error. The previous code silently fell back to `"gemini"`, /// which on machines without the Gemini CLI 400'd within 1 ms /// with `Agent 'Gemini CLI' CLI not found in PATH`. - pub async fn resolve_default_agent_type(&self) -> Result { + pub async fn resolve_default_agent_id(&self) -> Result { let providers = self .provider_repo .list() @@ -694,25 +697,37 @@ impl AssistantService { .map_err(|e| AssistantError::Internal(format!("failed to list providers: {e}")))?; if providers.iter().any(|p| p.enabled) { - Ok("aionrs".to_string()) + self.resolve_agent_id_for_agent_ref("aionrs").await } else { Err(AssistantError::BadRequest( "Cannot create assistant: no providers configured. Add a provider before creating an assistant, \ - or pass an explicit `preset_agent_type` in the request body." + or pass an explicit `agent_id` in the request body." .into(), )) } } - async fn resolve_agent_id_for_runtime_backend(&self, backend: &str) -> Result { - let trimmed = backend.trim(); + async fn resolve_runtime_backend_for_agent_id(&self, agent_id: &str) -> Result { + let trimmed = agent_id.trim(); + if trimmed.is_empty() { + return Err(AssistantError::BadRequest("agent_id is required".into())); + } let Some(binding) = resolve_agent_binding(&self.pool, trimmed) .await .map_err(|e| AssistantError::Internal(format!("resolve agent binding: {e}")))? else { - return Err(AssistantError::BadRequest(format!( - "Unknown preset_agent_type '{trimmed}'" - ))); + return Err(AssistantError::BadRequest(format!("Unknown agent_id '{trimmed}'"))); + }; + Ok(binding.runtime_backend) + } + + async fn resolve_agent_id_for_agent_ref(&self, agent_ref: &str) -> Result { + let trimmed = agent_ref.trim(); + let Some(binding) = resolve_agent_binding(&self.pool, trimmed) + .await + .map_err(|e| AssistantError::Internal(format!("resolve agent binding: {e}")))? + else { + return Err(AssistantError::BadRequest(format!("Unknown agent_ref '{trimmed}'"))); }; Ok(binding.agent_id) } @@ -760,21 +775,21 @@ impl AssistantService { let serialized = SerializedFields::from_create(&req)?; let detail_overrides = SerializedDetailOverrides::from_create(&req)?; - // Resolve the default agent type from the configured provider list - // when the caller did not supply one. Avoids the historical + // Resolve the default agent id from the configured provider list when + // the caller did not supply one. Avoids the historical // `"gemini"` fallback that 400'd within 1 ms on machines without // the Gemini CLI (ELECTRON-1J1, ELECTRON-1KV). - let resolved_agent_type = match req.preset_agent_type.as_deref() { - Some(s) if !s.is_empty() => s.to_string(), - _ => self.resolve_default_agent_type().await?, + let resolved_agent_id = match req.agent_id.as_deref() { + Some(s) if !s.trim().is_empty() => s.trim().to_string(), + _ => self.resolve_default_agent_id().await?, }; + self.resolve_runtime_backend_for_agent_id(&resolved_agent_id).await?; let avatar = self.normalize_user_avatar_input(&id, req.avatar.as_deref())?; let params = CreateAssistantParams { id: &id, name: &name, description: req.description.as_deref(), avatar: avatar.as_deref(), - preset_agent_type: &resolved_agent_type, enabled_skills: serialized.enabled_skills.as_deref(), custom_skill_names: serialized.custom_skill_names.as_deref(), disabled_builtin_skills: serialized.disabled_builtin_skills.as_deref(), @@ -786,7 +801,8 @@ impl AssistantService { }; let row = self.repo.create(¶ms).await?; - self.upsert_definition_from_legacy_user_row(&row).await?; + self.upsert_definition_from_legacy_user_row(&row, Some(&resolved_agent_id)) + .await?; self.apply_detail_overrides(&row.id, detail_overrides, false).await?; if let Some(definition) = self.definition_repo.get_by_assistant_id(&row.id).await? { self.sync_preferences_from_defaults_request(&definition, None, req.defaults.as_ref()) @@ -805,7 +821,7 @@ impl AssistantService { .is_some_and(|defaults| defaults.skills.is_some() || defaults.mcps.is_some()); // Built-in rows are sourced from the embedded bundle and can't - // be mutated. Users may still override `preset_agent_type`, and + // be mutated. Users may still override `agent_id`, and // product-defined governance allows model/permission defaults // to vary per built-in assistant. Any other field on the // request is rejected so callers don't silently lose data. @@ -825,15 +841,10 @@ impl AssistantService { || builtin_defaults_forbidden { return Err(AssistantError::Forbidden( - "Only 'preset_agent_type', 'defaults.model', and 'defaults.permission' can be overridden on built-in assistants".into(), + "Only 'agent_id', 'defaults.model', and 'defaults.permission' can be overridden on built-in assistants".into(), )); } - let preset_agent_type = req.preset_agent_type.as_deref().ok_or_else(|| { - AssistantError::BadRequest( - "'preset_agent_type' is required when updating a built-in assistant".into(), - ) - })?; let definition = self .definition_repo .get_by_assistant_id(id) @@ -844,7 +855,7 @@ impl AssistantService { let enabled = existing.as_ref().is_none_or(|o| o.enabled); let sort_order = existing.as_ref().map(|o| o.sort_order).unwrap_or(0); let last_used_at = existing.as_ref().and_then(|o| o.last_used_at); - let requested_agent_id = self.resolve_agent_id_for_runtime_backend(preset_agent_type).await?; + let requested_agent_id = req.agent_id.as_deref().map(|agent_id| agent_id.trim().to_string()); let current_agent_id = self .state_repo .get(&definition.id) @@ -852,17 +863,22 @@ impl AssistantService { .map_err(|e| AssistantError::Internal(format!("get assistant overlay: {e}")))? .and_then(|row| row.agent_id_override) .unwrap_or_else(|| definition.agent_id.clone()); - let reset_model_and_permission = current_agent_id != requested_agent_id; - self.state_repo - .upsert(&UpsertAssistantOverlayParams { - assistant_definition_id: &definition.id, - enabled, - sort_order, - agent_id_override: Some(&requested_agent_id), - last_used_at, - }) - .await - .map_err(|e| AssistantError::Internal(format!("upsert assistant overlay: {e}")))?; + let reset_model_and_permission = requested_agent_id + .as_deref() + .is_some_and(|agent_id| agent_id != current_agent_id); + if let Some(requested_agent_id) = requested_agent_id.as_deref() { + self.resolve_runtime_backend_for_agent_id(requested_agent_id).await?; + self.state_repo + .upsert(&UpsertAssistantOverlayParams { + assistant_definition_id: &definition.id, + enabled, + sort_order, + agent_id_override: Some(requested_agent_id), + last_used_at, + }) + .await + .map_err(|e| AssistantError::Internal(format!("upsert assistant overlay: {e}")))?; + } self.apply_detail_overrides(id, detail_overrides, reset_model_and_permission) .await?; let definition = self @@ -893,10 +909,14 @@ impl AssistantService { .get_by_assistant_id(id) .await? .ok_or_else(|| AssistantError::NotFound(format!("assistant '{id}' not found")))?; - let requested_agent_id = match req.preset_agent_type.as_deref() { - Some(preset_agent_type) => Some(self.resolve_agent_id_for_runtime_backend(preset_agent_type).await?), + let requested_agent_id = match req.agent_id.as_deref() { + Some(agent_id) if !agent_id.trim().is_empty() => Some(agent_id.trim().to_string()), + Some(_) => return Err(AssistantError::BadRequest("agent_id is required".into())), None => None, }; + if let Some(agent_id) = requested_agent_id.as_deref() { + self.resolve_runtime_backend_for_agent_id(agent_id).await?; + } let reset_model_and_permission = requested_agent_id .as_deref() .is_some_and(|agent_id| agent_id != current_definition.agent_id); @@ -909,7 +929,6 @@ impl AssistantService { name: req.name.as_deref(), description: req.description.as_ref().map(|s| Some(s.as_str())), avatar: normalized_avatar.as_ref().map(|value| value.as_deref()), - preset_agent_type: req.preset_agent_type.as_deref(), enabled_skills: serialized.enabled_skills.as_ref().map(|s| Some(s.as_str())), custom_skill_names: serialized.custom_skill_names.as_ref().map(|s| Some(s.as_str())), disabled_builtin_skills: serialized.disabled_builtin_skills.as_ref().map(|s| Some(s.as_str())), @@ -925,7 +944,8 @@ impl AssistantService { .update(id, ¶ms) .await? .ok_or_else(|| AssistantError::NotFound(format!("assistant '{id}' not found")))?; - self.upsert_definition_from_legacy_user_row(&row).await?; + self.upsert_definition_from_legacy_user_row(&row, requested_agent_id.as_deref()) + .await?; self.apply_detail_overrides(id, detail_overrides, reset_model_and_permission) .await?; if let Some(definition) = self.definition_repo.get_by_assistant_id(id).await? { @@ -1156,17 +1176,9 @@ impl AssistantService { .last_used_at .or_else(|| existing_state.as_ref().and_then(|state| state.last_used_at)) .or_else(|| existing.as_ref().and_then(|o| o.last_used_at)); - let agent_id_override = if let Some(value) = existing_state + let agent_id_override = existing_state .as_ref() - .and_then(|state| state.agent_id_override.clone()) - { - Some(value) - } else { - match existing.as_ref().and_then(|o| o.preset_agent_type.as_deref()) { - Some(value) => Some(self.resolve_agent_id_for_runtime_backend(value).await?), - None => None, - } - }; + .and_then(|state| state.agent_id_override.clone()); let state = self .state_repo .upsert(&UpsertAssistantOverlayParams { @@ -1195,10 +1207,10 @@ impl AssistantService { pub async fn import(&self, req: ImportAssistantsRequest) -> Result { let mut result = ImportAssistantsResult::default(); - // Resolved-once cache for the inferred default agent type. We only + // Resolved-once cache for the inferred default agent id. We only // hit the provider repo when at least one row in the batch omits - // `preset_agent_type` AND has cleared all the other skip conditions. - let mut cached_default_agent_type: Option = None; + // `agent_id` AND has cleared all the other skip conditions. + let mut cached_default_agent_id: Option = None; for entry in req.assistants { let id = entry @@ -1249,15 +1261,23 @@ impl AssistantService { } }; - // Mirror the create() path: prefer the caller-supplied value; + // Mirror the create() path: prefer the caller-supplied agent id; // otherwise infer from the configured provider list. - let resolved_agent_type = match entry.preset_agent_type.as_deref() { - Some(s) if !s.is_empty() => s.to_string(), - _ => match cached_default_agent_type.as_deref() { + let resolved_agent_id = match entry.agent_id.as_deref() { + Some(s) if !s.trim().is_empty() => s.trim().to_string(), + Some(_) => { + result.failed += 1; + result.errors.push(ImportError { + id, + error: "agent_id is required".into(), + }); + continue; + } + _ => match cached_default_agent_id.as_deref() { Some(v) => v.to_string(), - None => match self.resolve_default_agent_type().await { + None => match self.resolve_default_agent_id().await { Ok(v) => { - cached_default_agent_type = Some(v.clone()); + cached_default_agent_id = Some(v.clone()); v } Err(e) => { @@ -1271,6 +1291,14 @@ impl AssistantService { }, }, }; + if let Err(e) = self.resolve_runtime_backend_for_agent_id(&resolved_agent_id).await { + result.failed += 1; + result.errors.push(ImportError { + id, + error: e.to_string(), + }); + continue; + } let avatar = match self.normalize_user_avatar_input(&id, entry.avatar.as_deref()) { Ok(value) => value, @@ -1289,7 +1317,6 @@ impl AssistantService { name: &name, description: entry.description.as_deref(), avatar: avatar.as_deref(), - preset_agent_type: &resolved_agent_type, enabled_skills: serialized.enabled_skills.as_deref(), custom_skill_names: serialized.custom_skill_names.as_deref(), disabled_builtin_skills: serialized.disabled_builtin_skills.as_deref(), @@ -1302,7 +1329,8 @@ impl AssistantService { match self.repo.create(¶ms).await { Ok(row) => { - self.upsert_definition_from_legacy_user_row(&row).await?; + self.upsert_definition_from_legacy_user_row(&row, Some(&resolved_agent_id)) + .await?; result.imported += 1; } Err(aionui_db::DbError::Conflict(_)) => { @@ -1778,7 +1806,7 @@ fn definition_to_response( enabled: state.is_none_or(|row| row.enabled), sort_order: state.map(|row| row.sort_order).unwrap_or(0), agent_id: projection.agent_id.clone(), - preset_agent_type: projection.runtime_backend.clone(), + agent: projection.agent.clone(), enabled_skills: decode_str_list(Some(definition.default_skill_ids.as_str()))?, custom_skill_names: decode_str_list(Some(definition.custom_skill_names.as_str()))?, disabled_builtin_skills: decode_str_list(Some(definition.default_disabled_builtin_skill_ids.as_str()))?, @@ -1847,7 +1875,7 @@ fn definition_to_detail_response( }, engine: AssistantEngineResponse { agent_id: projection.agent_id.clone(), - agent_backend: projection.runtime_backend.clone(), + agent: projection.agent.clone(), }, rules: AssistantRulesResponse { content: if rules_content.is_empty() { @@ -1897,7 +1925,7 @@ fn definition_to_detail_response( #[derive(Debug, Clone)] struct AssistantRuntimeProjection { agent_id: String, - runtime_backend: String, + agent: Option, agent_status: AgentManagementStatus, agent_status_message: Option, team_selectable: bool, @@ -1949,12 +1977,15 @@ fn assistant_projection_for_definition( }) .or_else(|| agent_rows.iter().find(row_matches_backend)) }; - let runtime_backend = agent_row - .map(runtime_backend_for_management_row) - .unwrap_or_else(|| fallback_runtime_backend.to_owned()); let agent_id = agent_row .map(|row| row.id.clone()) .unwrap_or_else(|| effective_agent_id.to_owned()); + let agent = agent_row.map(|row| AssistantAgentResponse { + id: row.id.clone(), + r#type: row.agent_type, + source: row.agent_source, + backend: row.backend.clone(), + }); let agent_status = agent_row .map(|row| row.status) @@ -1985,7 +2016,7 @@ fn assistant_projection_for_definition( AssistantRuntimeProjection { agent_id, - runtime_backend, + agent, agent_status, agent_status_message, team_selectable: enabled @@ -2004,15 +2035,6 @@ fn effective_agent_id_for_definition<'a>( .unwrap_or(definition.agent_id.as_str()) } -fn runtime_backend_for_management_row(row: &AgentManagementRow) -> String { - row.backend - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| row.agent_type.serde_name()) - .to_owned() -} - // --------------------------------------------------------------------------- // Serialization helpers // --------------------------------------------------------------------------- @@ -2405,7 +2427,7 @@ mod tests { serde_json::json!({ "id": b.id, "name": b.name, - "preset_agent_type": b.preset_agent_type, + "agent_ref": b.agent_ref, "rule_file": b.rule_file, }) }) @@ -2476,7 +2498,7 @@ mod tests { description: None, description_i18n: HashMap::new(), avatar: None, - preset_agent_type: "gemini".into(), + agent_ref: "gemini".into(), enabled_skills: Vec::new(), custom_skill_names: Vec::new(), disabled_builtin_skills: Vec::new(), @@ -2631,7 +2653,7 @@ mod tests { .find(|assistant| assistant.id == "bare:agent-claude") .unwrap(); assert_eq!(bare.source, AssistantSource::Bare); - assert_eq!(bare.preset_agent_type, "claude"); + assert_eq!(bare.agent_id, "agent-claude"); assert_eq!(bare.agent_status, aionui_api_types::AgentManagementStatus::Online); assert!(bare.team_selectable); assert!(!bare.deletable); @@ -2641,9 +2663,8 @@ mod tests { async fn bootstrap_falls_back_to_agent_type_when_backend_is_empty() { // Engines like Aion CLI carry their identity in `agent_type` and leave // `backend` empty (it is an ACP-vendor label). The bare assistant must - // still expose a concrete `preset_agent_type` so the frontend can route - // it as an aionrs conversation; otherwise the top-level model is dropped - // and warmup fails with "Provider '' not found". + // still expose the concrete agent id so the frontend does not bind it + // through an overloaded runtime backend label. let mut agent_row = mk_agent_row( "agent-aionrs", "aionrs", @@ -2663,7 +2684,7 @@ mod tests { .iter() .find(|assistant| assistant.id == "bare:agent-aionrs") .unwrap(); - assert_eq!(bare.preset_agent_type, "aionrs"); + assert_eq!(bare.agent_id, "agent-aionrs"); } #[tokio::test] @@ -2681,7 +2702,7 @@ mod tests { aionrs_row.agent_type = aionui_common::AgentType::Aionrs; let mut builtin = mk_builtin("builtin-aionrs", "Aion Assistant"); - builtin.preset_agent_type = "aionrs".into(); + builtin.agent_ref = "aionrs".into(); let fx = fixture_with_options(FixtureOpts { builtins: vec![builtin], @@ -2953,21 +2974,21 @@ mod tests { } #[tokio::test] - async fn update_builtin_preset_agent_type_writes_override() { + async fn update_builtin_agent_id_writes_override() { let fx = fixture_with_builtins(vec![mk_builtin("builtin-office", "Office")]).await; let updated = fx .service .update( "builtin-office", UpdateAssistantRequest { - preset_agent_type: Some("claude".into()), + agent_id: Some("2d23ff1c".into()), ..Default::default() }, ) .await .unwrap(); assert_eq!(updated.source, AssistantSource::Builtin); - assert_eq!(updated.preset_agent_type, "claude"); + assert_eq!(updated.agent_id, "2d23ff1c"); // List view must reflect the override too. let listed = fx .service @@ -2977,7 +2998,7 @@ mod tests { .into_iter() .find(|a| a.id == "builtin-office") .unwrap(); - assert_eq!(listed.preset_agent_type, "claude"); + assert_eq!(listed.agent_id, "2d23ff1c"); } #[tokio::test] @@ -2988,7 +3009,7 @@ mod tests { .update( "builtin-office", UpdateAssistantRequest { - preset_agent_type: Some("gemini".into()), + agent_id: Some("cc126dd5".into()), defaults: Some(AssistantDefaultsRequest { model: Some(AssistantDefaultScalarRequest { mode: "fixed".into(), @@ -3007,7 +3028,7 @@ mod tests { .unwrap(); assert_eq!(updated.source, AssistantSource::Builtin); - assert_eq!(updated.preset_agent_type, "gemini"); + assert_eq!(updated.agent_id, "cc126dd5"); let detail = fx.service.get_detail("builtin-office", Some("en-US")).await.unwrap(); assert_eq!(detail.defaults.model.mode, "fixed"); @@ -3049,7 +3070,7 @@ mod tests { .update( "builtin-office", UpdateAssistantRequest { - preset_agent_type: Some("gemini".into()), + agent_id: Some("cc126dd5".into()), defaults: Some(AssistantDefaultsRequest { model: Some(AssistantDefaultScalarRequest { mode: "fixed".into(), @@ -3071,7 +3092,7 @@ mod tests { .update( "builtin-office", UpdateAssistantRequest { - preset_agent_type: Some("claude".into()), + agent_id: Some("2d23ff1c".into()), ..Default::default() }, ) @@ -3079,7 +3100,7 @@ mod tests { .unwrap(); let detail = fx.service.get_detail("builtin-office", Some("en-US")).await.unwrap(); - assert_eq!(detail.engine.agent_backend, "claude"); + assert_eq!(detail.engine.agent_id, "2d23ff1c"); assert_eq!(detail.defaults.model.mode, "auto"); assert_eq!(detail.defaults.model.value, None); assert_eq!(detail.defaults.permission.mode, "auto"); @@ -3153,7 +3174,7 @@ mod tests { .update( "u1", UpdateAssistantRequest { - preset_agent_type: Some("codex".into()), + agent_id: Some("8e1acf31".into()), ..Default::default() }, ) @@ -3161,7 +3182,7 @@ mod tests { .unwrap(); let detail = fx.service.get_detail("u1", Some("en-US")).await.unwrap(); - assert_eq!(detail.engine.agent_backend, "codex"); + assert_eq!(detail.engine.agent_id, "8e1acf31"); assert_eq!(detail.defaults.model.mode, "auto"); assert_eq!(detail.defaults.model.value, None); assert_eq!(detail.defaults.permission.mode, "auto"); @@ -3681,7 +3702,7 @@ mod tests { "assistants": [{ "id": "builtin-office", "name": "Office", - "preset_agent_type": "gemini", + "agent_ref": "gemini", "rule_file": "rules/office.{locale}.md", }] }); @@ -3736,7 +3757,7 @@ mod tests { } // ----------------------------------------------------------------------- - // Default agent-type inference (ELECTRON-1J1 / 1KV regression coverage) + // Default agent inference (ELECTRON-1J1 / 1KV regression coverage) // ----------------------------------------------------------------------- /// Anthropic provider routes to AionRS, not the Claude Code CLI: @@ -3744,52 +3765,52 @@ mod tests { /// `claude` on `PATH`. CLI-based agents must be opted into /// explicitly. #[tokio::test] - async fn resolve_default_agent_type_routes_anthropic_provider_to_aionrs() { + async fn resolve_default_agent_id_routes_anthropic_provider_to_aionrs() { let fx = fixture_with_options(FixtureOpts { seed_platform: Some("anthropic"), ..Default::default() }) .await; - let resolved = fx.service.resolve_default_agent_type().await.unwrap(); - assert_eq!(resolved, "aionrs"); + let resolved = fx.service.resolve_default_agent_id().await.unwrap(); + assert_eq!(resolved, "632f31d2"); } /// OpenAI / custom provider falls back to AionRS, the only AionUI /// agent that doesn't require a third-party CLI. #[tokio::test] - async fn resolve_default_agent_type_falls_back_to_aionrs_for_openai_provider() { + async fn resolve_default_agent_id_falls_back_to_aionrs_for_openai_provider() { let fx = fixture_with_options(FixtureOpts { seed_platform: Some("openai"), ..Default::default() }) .await; - let resolved = fx.service.resolve_default_agent_type().await.unwrap(); - assert_eq!(resolved, "aionrs"); + let resolved = fx.service.resolve_default_agent_id().await.unwrap(); + assert_eq!(resolved, "632f31d2"); } /// Custom (non-anthropic, non-openai) platform also routes to AionRS, /// which handles OpenAI-compatible custom URLs. #[tokio::test] - async fn resolve_default_agent_type_handles_custom_platform_as_aionrs() { + async fn resolve_default_agent_id_handles_custom_platform_as_aionrs() { let fx = fixture_with_options(FixtureOpts { seed_platform: Some("custom"), ..Default::default() }) .await; - let resolved = fx.service.resolve_default_agent_type().await.unwrap(); - assert_eq!(resolved, "aionrs"); + let resolved = fx.service.resolve_default_agent_id().await.unwrap(); + assert_eq!(resolved, "632f31d2"); } /// No providers → loud BadRequest with actionable text. Crucially, /// this no longer silently falls through to `"gemini"`. #[tokio::test] - async fn resolve_default_agent_type_errors_when_no_providers() { + async fn resolve_default_agent_id_errors_when_no_providers() { let fx = fixture_with_options(FixtureOpts { no_default_provider: true, ..Default::default() }) .await; - let err = fx.service.resolve_default_agent_type().await.unwrap_err(); + let err = fx.service.resolve_default_agent_id().await.unwrap_err(); match err { AssistantError::BadRequest(msg) => { assert!( @@ -3808,7 +3829,7 @@ mod tests { /// Disabled providers do not satisfy the inference; the resolver /// must treat them as if they were absent. #[tokio::test] - async fn resolve_default_agent_type_ignores_disabled_providers() { + async fn resolve_default_agent_id_ignores_disabled_providers() { let fx = fixture_with_options(FixtureOpts { no_default_provider: true, ..Default::default() @@ -3837,17 +3858,17 @@ mod tests { .await .unwrap(); - let err = fx.service.resolve_default_agent_type().await.unwrap_err(); + let err = fx.service.resolve_default_agent_id().await.unwrap_err(); assert!(matches!(err, AssistantError::BadRequest(_))); } /// End-to-end regression for ELECTRON-1J1 / 1KV: creating an - /// assistant with no `preset_agent_type` and no Gemini CLI installed + /// assistant with no `agent_id` and no Gemini CLI installed /// must NOT default to `"gemini"`. Any enabled provider — Anthropic /// or otherwise — should resolve to `"aionrs"`, the only built-in /// agent that doesn't depend on a third-party CLI being on `PATH`. #[tokio::test] - async fn create_without_preset_does_not_default_to_gemini_when_provider_exists() { + async fn create_without_agent_id_does_not_default_to_gemini_when_provider_exists() { for platform in ["anthropic", "openai"] { let fx = fixture_with_options(FixtureOpts { seed_platform: Some(platform), @@ -3864,21 +3885,19 @@ mod tests { .await .unwrap(); assert_ne!( - created.preset_agent_type, "gemini", + created.agent_id, "gemini", "Gemini default would 400 within 1ms on machines without the CLI" ); assert_eq!( - created.preset_agent_type, "aionrs", + created.agent_id, "632f31d2", "{platform} provider should resolve to aionrs" ); } } - /// Explicit `preset_agent_type` in the request body wins over the - /// inferred default — callers that know what they want stay in - /// control. + /// Explicit `agent_id` in the request body wins over the inferred default. #[tokio::test] - async fn create_respects_explicit_preset_agent_type() { + async fn create_respects_explicit_agent_id() { let fx = fixture_with_options(FixtureOpts { seed_platform: Some("anthropic"), ..Default::default() @@ -3889,12 +3908,12 @@ mod tests { .create(CreateAssistantRequest { id: Some("u1".into()), name: "Mine".into(), - preset_agent_type: Some("codex".into()), + agent_id: Some("8e1acf31".into()), ..req_default() }) .await .unwrap(); - assert_eq!(created.preset_agent_type, "codex"); + assert_eq!(created.agent_id, "8e1acf31"); } fn req_default() -> CreateAssistantRequest { @@ -3903,7 +3922,7 @@ mod tests { name: String::new(), description: None, avatar: None, - preset_agent_type: None, + agent_id: None, enabled_skills: None, custom_skill_names: None, disabled_builtin_skills: None, diff --git a/crates/aionui-channel/tests/message_service_integration.rs b/crates/aionui-channel/tests/message_service_integration.rs index 27a49d7cb..ed092bcb1 100644 --- a/crates/aionui-channel/tests/message_service_integration.rs +++ b/crates/aionui-channel/tests/message_service_integration.rs @@ -354,8 +354,7 @@ async fn send_to_agent_persists_assistant_snapshot_for_channel_bound_assistant() .await .unwrap() .expect("acp_session row should exist for ACP assistant conversations"); - assert_eq!(session_row.agent_backend, "claude"); - assert!(!session_row.agent_id.is_empty()); + assert_eq!(session_row.agent_id, "2d23ff1c"); assert_eq!(snapshot.assistant_id, "bare-claude"); assert_eq!(snapshot.agent_id, "2d23ff1c"); assert_eq!(conversation.name, "Claude"); diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 00e4b3611..ee4717b41 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -1159,7 +1159,6 @@ impl ConversationService { let params = CreateAcpSessionParams { conversation_id, - agent_backend: backend, agent_source, agent_id: &resolved_agent_id, }; diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index e24e1f3bb..91e9cd27e 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -805,7 +805,6 @@ impl StubAcpSessionRepo { #[derive(Debug, Clone, PartialEq, Eq)] struct CreateAcpSessionCall { conversation_id: String, - agent_backend: String, agent_source: String, agent_id: String, } @@ -818,7 +817,6 @@ impl IAcpSessionRepository for StubAcpSessionRepo { async fn create(&self, params: &CreateAcpSessionParams<'_>) -> Result { self.create_calls.lock().unwrap().push(CreateAcpSessionCall { conversation_id: params.conversation_id.to_owned(), - agent_backend: params.agent_backend.to_owned(), agent_source: params.agent_source.to_owned(), agent_id: params.agent_id.to_owned(), }); @@ -826,7 +824,6 @@ impl IAcpSessionRepository for StubAcpSessionRepo { // succeed for ACP conversations in unit tests. Ok(AcpSessionRow { conversation_id: params.conversation_id.to_owned(), - agent_backend: params.agent_backend.to_owned(), agent_source: params.agent_source.to_owned(), agent_id: params.agent_id.to_owned(), session_id: None, @@ -5131,7 +5128,7 @@ async fn create_prefers_snapshot_runtime_identity_over_legacy_extra_identity() { let create_calls = acp_repo.create_calls(); assert_eq!(create_calls.len(), 1); - assert_eq!(create_calls[0].agent_backend, "codex"); + assert_eq!(create_calls[0].agent_id, "8e1acf31"); assert_eq!(create_calls[0].agent_source, "builtin"); assert_eq!(create_calls[0].agent_id, "8e1acf31"); } diff --git a/crates/aionui-conversation/src/session_context.rs b/crates/aionui-conversation/src/session_context.rs index 3c38f2fc0..3e6760886 100644 --- a/crates/aionui-conversation/src/session_context.rs +++ b/crates/aionui-conversation/src/session_context.rs @@ -233,14 +233,20 @@ impl<'a> SessionContextBuilder<'a> { } if let Some(session_row) = session_row.filter(|row| !row.agent_id.is_empty()) { + let metadata = self + .agent_metadata_repo + .get(&session_row.agent_id) + .await + .map_err(|e| ConversationError::internal(format!("agent_metadata lookup: {e}")))?; debug!( conversation_id = %row.id, agent_id = %session_row.agent_id, - backend = %session_row.agent_backend, "session_context: restored ACP identity from persisted acp_session row" ); config.agent_id = Some(session_row.agent_id.clone()); - config.backend = Some(session_row.agent_backend.clone()); + if let Some(metadata) = metadata { + config.backend = metadata.backend; + } return Ok(()); } @@ -634,7 +640,6 @@ mod tests { .acp_session_repo .create(&CreateAcpSessionParams { conversation_id: "conv-1", - agent_backend: "claude", agent_source: "builtin", agent_id: "builtin-claude-test", }) @@ -681,7 +686,6 @@ mod tests { .acp_session_repo .create(&CreateAcpSessionParams { conversation_id: "conv-1", - agent_backend: "codex", agent_source: "builtin", agent_id: "builtin-codex-test", }) diff --git a/crates/aionui-cron/src/executor.rs b/crates/aionui-cron/src/executor.rs index 5b1a7feb3..b39310470 100644 --- a/crates/aionui-cron/src/executor.rs +++ b/crates/aionui-cron/src/executor.rs @@ -1257,7 +1257,6 @@ mod tests { is_preset: None, assistant_id: Some("assistant-sample".into()), custom_agent_id: None, - preset_agent_type: None, mode: None, model_id: Some("claude-sonnet-4".into()), model: None, @@ -1544,7 +1543,6 @@ mod tests { is_preset: None, assistant_id: None, custom_agent_id: None, - preset_agent_type: None, mode: None, model_id: Some("gpt-5".into()), model: Some(ProviderWithModel { @@ -1572,7 +1570,6 @@ mod tests { is_preset: None, assistant_id: None, custom_agent_id: None, - preset_agent_type: None, mode: None, model_id: None, model: Some(ProviderWithModel { @@ -1612,7 +1609,6 @@ mod tests { is_preset: None, assistant_id: None, custom_agent_id: None, - preset_agent_type: None, mode: None, model_id: Some("gpt-5".into()), model: None, diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index d06b96aa3..fb69984bf 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -1080,7 +1080,6 @@ impl CronService { is_preset: None, assistant_id: config.assistant_id, custom_agent_id: None, - preset_agent_type: None, mode: config.mode, model_id: config.model_id, model: normalize_model(config.model, runtime_agent_type)?, @@ -1684,7 +1683,6 @@ mod tests { let err = serde_json::from_value::(serde_json::json!({ "name": "Helper", "custom_agent_id": "legacy-assistant", - "preset_agent_type": "claude", "mode": "default", "model_id": "claude-sonnet-4", })) @@ -1813,7 +1811,6 @@ mod tests { is_preset: None, assistant_id: Some("assistant-1".into()), custom_agent_id: None, - preset_agent_type: None, mode: Some("default".into()), model_id: Some("claude-sonnet-4".into()), model: None, @@ -1847,7 +1844,6 @@ mod tests { assert_eq!(config.assistant_id.as_deref(), Some("assistant-1")); assert!(config.custom_agent_id.is_none()); - assert!(config.preset_agent_type.is_none()); assert!(config.is_preset.is_none()); } @@ -1856,7 +1852,6 @@ mod tests { let err = serde_json::from_value::(serde_json::json!({ "name": "Helper", "custom_agent_id": "legacy-assistant", - "preset_agent_type": "claude", "mode": "default", "model_id": "claude-sonnet-4", })) diff --git a/crates/aionui-cron/src/types.rs b/crates/aionui-cron/src/types.rs index abadaf03f..36f00c100 100644 --- a/crates/aionui-cron/src/types.rs +++ b/crates/aionui-cron/src/types.rs @@ -135,8 +135,6 @@ pub struct CronAgentConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub custom_agent_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub preset_agent_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, @@ -344,11 +342,6 @@ pub fn cron_job_to_response(job: &CronJob) -> CronJobResponse { } else { c.custom_agent_id.clone() }, - preset_agent_type: if assistant_backed { - None - } else { - c.preset_agent_type.clone() - }, mode: c.mode.clone(), model_id: c.model_id.clone(), model: c.model.clone(), @@ -602,7 +595,6 @@ mod tests { is_preset: None, assistant_id: None, custom_agent_id: None, - preset_agent_type: None, mode: None, model_id: None, model: None, @@ -835,7 +827,6 @@ mod tests { is_preset: Some(true), assistant_id: Some("assistant-1".into()), custom_agent_id: Some("legacy-assistant".into()), - preset_agent_type: Some("codex".into()), mode: Some("full-access".into()), model_id: Some("gpt-5-codex".into()), model: None, @@ -852,7 +843,6 @@ mod tests { assert!(config.cli_path.is_none()); assert!(config.is_preset.is_none()); assert!(config.custom_agent_id.is_none()); - assert!(config.preset_agent_type.is_none()); assert_eq!(config.mode.as_deref(), Some("full-access")); assert_eq!(config.model_id.as_deref(), Some("gpt-5-codex")); assert_eq!( @@ -884,7 +874,6 @@ mod tests { is_preset: Some(false), assistant_id: None, custom_agent_id: Some("legacy-assistant".into()), - preset_agent_type: None, mode: Some("default".into()), model_id: Some("claude-sonnet-4".into()), model: None, @@ -912,7 +901,6 @@ mod tests { is_preset: None, assistant_id: Some("bare-gemini".into()), custom_agent_id: None, - preset_agent_type: None, mode: Some("default".into()), model_id: Some("gemini-2.5-pro".into()), model: Some(ProviderWithModel { diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index 1109da03b..2a2429104 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -1024,7 +1024,6 @@ async fn create_job_strips_legacy_agent_ids_when_assistant_id_present() { assert_eq!(config.assistant_id.as_deref(), Some("assistant-1")); assert!(config.custom_agent_id.is_none()); - assert!(config.preset_agent_type.is_none()); assert!(config.is_preset.is_none()); } @@ -1338,7 +1337,6 @@ async fn update_job_strips_legacy_agent_ids_when_assistant_id_present() { assert_eq!(config.assistant_id.as_deref(), Some("assistant-1")); assert!(config.custom_agent_id.is_none()); - assert!(config.preset_agent_type.is_none()); assert!(config.is_preset.is_none()); } diff --git a/crates/aionui-db/migrations/013_agent_connection_snapshot.sql b/crates/aionui-db/migrations/013_agent_connection_snapshot.sql index 37c1ce0fa..803d2df82 100644 --- a/crates/aionui-db/migrations/013_agent_connection_snapshot.sql +++ b/crates/aionui-db/migrations/013_agent_connection_snapshot.sql @@ -21,7 +21,7 @@ ALTER TABLE agent_metadata ADD COLUMN env_override TEXT; -- Assistant/agent unification: assistant storage now binds to the concrete -- agent catalog row. Runtime backend labels remain compatibility/runtime --- fields on legacy mirrors, conversation extra, and acp_session. +-- fields on legacy mirrors and conversation extra. -- -- Assistant identity boundary: -- - `assistant_definitions.id` is the internal definition row id. @@ -204,6 +204,116 @@ CREATE INDEX IF NOT EXISTS idx_assistant_overlays_agent_id_override CREATE INDEX IF NOT EXISTS idx_conversation_assistant_snapshots_agent_id ON conversation_assistant_snapshots(agent_id); +-- ACP sessions persist only the concrete agent catalog row. Historical rows +-- may have only `agent_backend`; recover that into `agent_id` before dropping +-- the ambiguous runtime label column. +DROP INDEX IF EXISTS idx_acp_session_status; +DROP INDEX IF EXISTS idx_acp_session_suspended; +DROP INDEX IF EXISTS idx_acp_session_agent_id; + +CREATE TABLE _acp_session_new ( + conversation_id TEXT PRIMARY KEY, + agent_source TEXT NOT NULL, + agent_id TEXT NOT NULL, + session_id TEXT, + session_status TEXT NOT NULL DEFAULT 'idle', + session_config TEXT NOT NULL DEFAULT '{}', + last_active_at INTEGER, + suspended_at INTEGER +); + +INSERT INTO _acp_session_new ( + conversation_id, agent_source, agent_id, session_id, session_status, + session_config, last_active_at, suspended_at +) +SELECT + conversation_id, + agent_source, + COALESCE( + NULLIF(agent_id, ''), + ( + SELECT am.id + FROM agent_metadata am + WHERE am.id = acp_session.agent_backend + OR am.backend = acp_session.agent_backend + OR am.agent_type = acp_session.agent_backend + ORDER BY + CASE am.agent_source + WHEN 'builtin' THEN 0 + ELSE 1 + END, + am.sort_order, + am.id + LIMIT 1 + ), + '' + ), + session_id, + session_status, + session_config, + last_active_at, + suspended_at +FROM acp_session; + +DROP TABLE acp_session; +ALTER TABLE _acp_session_new RENAME TO acp_session; + +CREATE INDEX IF NOT EXISTS idx_acp_session_status ON acp_session(session_status); +CREATE INDEX IF NOT EXISTS idx_acp_session_suspended ON acp_session(session_status, suspended_at) WHERE session_status = 'suspended'; +CREATE INDEX IF NOT EXISTS idx_acp_session_agent_id ON acp_session(agent_id); + +-- Drop legacy assistant runtime-backend mirror columns after their values have +-- already been normalized into assistant_definitions.agent_id / +-- assistant_overlays.agent_id_override above. +CREATE TABLE IF NOT EXISTS _assistants_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + avatar TEXT, + enabled_skills TEXT, + custom_skill_names TEXT, + disabled_builtin_skills TEXT, + prompts TEXT, + models TEXT, + name_i18n TEXT, + description_i18n TEXT, + prompts_i18n TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +INSERT OR IGNORE INTO _assistants_new + (id, name, description, avatar, enabled_skills, custom_skill_names, + disabled_builtin_skills, prompts, models, name_i18n, description_i18n, + prompts_i18n, created_at, updated_at) +SELECT + id, name, description, avatar, enabled_skills, custom_skill_names, + disabled_builtin_skills, prompts, models, name_i18n, description_i18n, + prompts_i18n, created_at, updated_at +FROM assistants; + +ALTER TABLE assistants RENAME TO _assistants_old; +ALTER TABLE _assistants_new RENAME TO assistants; +DROP TABLE IF EXISTS _assistants_old; +CREATE INDEX IF NOT EXISTS idx_assistants_updated_at ON assistants(updated_at DESC); + +CREATE TABLE IF NOT EXISTS _assistant_overrides_new ( + assistant_id TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + last_used_at INTEGER, + updated_at INTEGER NOT NULL +); + +INSERT OR IGNORE INTO _assistant_overrides_new + (assistant_id, enabled, sort_order, last_used_at, updated_at) +SELECT assistant_id, enabled, sort_order, last_used_at, updated_at +FROM assistant_overrides; + +ALTER TABLE assistant_overrides RENAME TO _assistant_overrides_old; +ALTER TABLE _assistant_overrides_new RENAME TO assistant_overrides; +DROP TABLE IF EXISTS _assistant_overrides_old; + -- Cron assistant-first cleanup: -- - `cron_jobs.agent_type` is derived from assistant identity at runtime. -- - `agent_config.backend` was overloaded. For aionrs rows it held the LLM diff --git a/crates/aionui-db/src/models/acp_session.rs b/crates/aionui-db/src/models/acp_session.rs index 904d3df7c..e84a7e32e 100644 --- a/crates/aionui-db/src/models/acp_session.rs +++ b/crates/aionui-db/src/models/acp_session.rs @@ -8,7 +8,6 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct AcpSessionRow { pub conversation_id: String, - pub agent_backend: String, pub agent_source: String, pub agent_id: String, pub session_id: Option, diff --git a/crates/aionui-db/src/models/assistant.rs b/crates/aionui-db/src/models/assistant.rs index 8e932a014..94da89396 100644 --- a/crates/aionui-db/src/models/assistant.rs +++ b/crates/aionui-db/src/models/assistant.rs @@ -14,7 +14,6 @@ pub struct AssistantRow { pub name: String, pub description: Option, pub avatar: Option, - pub preset_agent_type: String, pub enabled_skills: Option, pub custom_skill_names: Option, pub disabled_builtin_skills: Option, @@ -29,16 +28,12 @@ pub struct AssistantRow { /// Row mapping for the `assistant_overrides` table (per-assistant user state). /// -/// `preset_agent_type` is `Some(_)` when the user has switched the main agent -/// on a built-in assistant (which cannot be mutated at its source). `None` -/// means "inherit from the built-in / user row". #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct AssistantOverrideRow { pub assistant_id: String, pub enabled: bool, pub sort_order: i32, pub last_used_at: Option, - pub preset_agent_type: Option, pub updated_at: TimestampMs, } @@ -114,7 +109,6 @@ pub struct CreateAssistantParams<'a> { pub name: &'a str, pub description: Option<&'a str>, pub avatar: Option<&'a str>, - pub preset_agent_type: &'a str, pub enabled_skills: Option<&'a str>, pub custom_skill_names: Option<&'a str>, pub disabled_builtin_skills: Option<&'a str>, @@ -133,7 +127,6 @@ pub struct UpdateAssistantParams<'a> { pub name: Option<&'a str>, pub description: Option>, pub avatar: Option>, - pub preset_agent_type: Option<&'a str>, pub enabled_skills: Option>, pub custom_skill_names: Option>, pub disabled_builtin_skills: Option>, @@ -146,16 +139,12 @@ pub struct UpdateAssistantParams<'a> { /// Upsert parameters for `IAssistantOverrideRepository::upsert`. /// -/// `preset_agent_type` uses `Option>`: outer `None` keeps the -/// current value, outer `Some(inner)` writes `inner` (which itself may be -/// `None` to clear the override). #[derive(Debug, Clone, Default)] pub struct UpsertOverrideParams<'a> { pub assistant_id: &'a str, pub enabled: bool, pub sort_order: i32, pub last_used_at: Option, - pub preset_agent_type: Option>, } /// Insert-or-update parameters for `assistant_definitions`. diff --git a/crates/aionui-db/src/repository/acp_session.rs b/crates/aionui-db/src/repository/acp_session.rs index 9e8b225de..ccf40e24d 100644 --- a/crates/aionui-db/src/repository/acp_session.rs +++ b/crates/aionui-db/src/repository/acp_session.rs @@ -23,7 +23,6 @@ use crate::models::AcpSessionRow; #[derive(Debug, Clone)] pub struct CreateAcpSessionParams<'a> { pub conversation_id: &'a str, - pub agent_backend: &'a str, pub agent_source: &'a str, pub agent_id: &'a str, } diff --git a/crates/aionui-db/src/repository/sqlite_acp_session.rs b/crates/aionui-db/src/repository/sqlite_acp_session.rs index 909d57ae1..7c7980be8 100644 --- a/crates/aionui-db/src/repository/sqlite_acp_session.rs +++ b/crates/aionui-db/src/repository/sqlite_acp_session.rs @@ -39,12 +39,11 @@ impl IAcpSessionRepository for SqliteAcpSessionRepository { let now = now_ms(); sqlx::query( "INSERT INTO acp_session \ - (conversation_id, agent_backend, agent_source, agent_id, \ + (conversation_id, agent_source, agent_id, \ session_id, session_status, session_config, last_active_at) \ - VALUES (?, ?, ?, ?, NULL, 'idle', '{}', ?)", + VALUES (?, ?, ?, NULL, 'idle', '{}', ?)", ) .bind(params.conversation_id) - .bind(params.agent_backend) .bind(params.agent_source) .bind(params.agent_id) .bind(now) @@ -220,7 +219,6 @@ mod tests { fn create_params<'a>(conversation_id: &'a str) -> CreateAcpSessionParams<'a> { CreateAcpSessionParams { conversation_id, - agent_backend: "claude", agent_source: "builtin", agent_id: "2d23ff1c", } @@ -231,7 +229,7 @@ mod tests { let (repo, _db) = setup().await; let row = repo.create(&create_params("conv-1")).await.unwrap(); assert_eq!(row.conversation_id, "conv-1"); - assert_eq!(row.agent_backend, "claude"); + assert_eq!(row.agent_id, "2d23ff1c"); assert_eq!(row.session_id, None); assert_eq!(row.session_status, "idle"); assert_eq!(row.session_config, "{}"); diff --git a/crates/aionui-db/src/repository/sqlite_assistant.rs b/crates/aionui-db/src/repository/sqlite_assistant.rs index 6f98e9108..6555509be 100644 --- a/crates/aionui-db/src/repository/sqlite_assistant.rs +++ b/crates/aionui-db/src/repository/sqlite_assistant.rs @@ -3,7 +3,6 @@ use aionui_common::{TimestampMs, now_ms}; use sqlx::SqlitePool; -use crate::agent_binding::resolve_agent_binding; use crate::error::DbError; use crate::models::{ AssistantDefinitionRow, AssistantOverlayRow, AssistantOverrideRow, AssistantPreferenceRow, AssistantRow, @@ -53,16 +52,15 @@ impl IAssistantRepository for SqliteAssistantRepository { sqlx::query( "INSERT INTO assistants \ - (id, name, description, avatar, preset_agent_type, enabled_skills, \ + (id, name, description, avatar, enabled_skills, \ custom_skill_names, disabled_builtin_skills, prompts, models, \ name_i18n, description_i18n, prompts_i18n, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(params.id) .bind(params.name) .bind(params.description) .bind(params.avatar) - .bind(params.preset_agent_type) .bind(params.enabled_skills) .bind(params.custom_skill_names) .bind(params.disabled_builtin_skills) @@ -87,7 +85,6 @@ impl IAssistantRepository for SqliteAssistantRepository { name: params.name.to_string(), description: params.description.map(String::from), avatar: params.avatar.map(String::from), - preset_agent_type: params.preset_agent_type.to_string(), enabled_skills: params.enabled_skills.map(String::from), custom_skill_names: params.custom_skill_names.map(String::from), disabled_builtin_skills: params.disabled_builtin_skills.map(String::from), @@ -110,7 +107,7 @@ impl IAssistantRepository for SqliteAssistantRepository { sqlx::query( "UPDATE assistants SET \ - name = ?, description = ?, avatar = ?, preset_agent_type = ?, \ + name = ?, description = ?, avatar = ?, \ enabled_skills = ?, custom_skill_names = ?, disabled_builtin_skills = ?, \ prompts = ?, models = ?, name_i18n = ?, description_i18n = ?, \ prompts_i18n = ?, updated_at = ? \ @@ -119,7 +116,6 @@ impl IAssistantRepository for SqliteAssistantRepository { .bind(&merged.name) .bind(&merged.description) .bind(&merged.avatar) - .bind(&merged.preset_agent_type) .bind(&merged.enabled_skills) .bind(&merged.custom_skill_names) .bind(&merged.disabled_builtin_skills) @@ -149,15 +145,14 @@ impl IAssistantRepository for SqliteAssistantRepository { sqlx::query( "INSERT INTO assistants \ - (id, name, description, avatar, preset_agent_type, enabled_skills, \ + (id, name, description, avatar, enabled_skills, \ custom_skill_names, disabled_builtin_skills, prompts, models, \ name_i18n, description_i18n, prompts_i18n, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ ON CONFLICT(id) DO UPDATE SET \ name = excluded.name, \ description = excluded.description, \ avatar = excluded.avatar, \ - preset_agent_type = excluded.preset_agent_type, \ enabled_skills = excluded.enabled_skills, \ custom_skill_names = excluded.custom_skill_names, \ disabled_builtin_skills = excluded.disabled_builtin_skills, \ @@ -172,7 +167,6 @@ impl IAssistantRepository for SqliteAssistantRepository { .bind(params.name) .bind(params.description) .bind(params.avatar) - .bind(params.preset_agent_type) .bind(params.enabled_skills) .bind(params.custom_skill_names) .bind(params.disabled_builtin_skills) @@ -201,10 +195,6 @@ fn merge_update(existing: AssistantRow, params: &UpdateAssistantParams<'_>) -> A name: params.name.map(String::from).unwrap_or(existing.name), description: params.description.map_or(existing.description, |v| v.map(String::from)), avatar: params.avatar.map_or(existing.avatar, |v| v.map(String::from)), - preset_agent_type: params - .preset_agent_type - .map(String::from) - .unwrap_or(existing.preset_agent_type), enabled_skills: params .enabled_skills .map_or(existing.enabled_skills, |v| v.map(String::from)), @@ -297,34 +287,21 @@ impl IAssistantOverrideRepository for SqliteAssistantOverrideRepository { let now = now_ms(); let last_used_at: Option = params.last_used_at; - // `preset_agent_type` has three-way semantics in the params struct - // (see `UpsertOverrideParams`). At the SQL layer we flatten it into a - // `(write?, value)` pair: on CONFLICT, if the caller did not specify - // a new value, `COALESCE(new_flag, 0)` keeps the existing column. - let (pat_write, pat_value): (bool, Option<&str>) = match params.preset_agent_type { - Some(v) => (true, v), - None => (false, None), - }; - sqlx::query( "INSERT INTO assistant_overrides \ - (assistant_id, enabled, sort_order, last_used_at, preset_agent_type, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?) \ + (assistant_id, enabled, sort_order, last_used_at, updated_at) \ + VALUES (?, ?, ?, ?, ?) \ ON CONFLICT(assistant_id) DO UPDATE SET \ enabled = excluded.enabled, \ sort_order = excluded.sort_order, \ last_used_at = COALESCE(excluded.last_used_at, assistant_overrides.last_used_at), \ - preset_agent_type = CASE WHEN ? THEN ? ELSE assistant_overrides.preset_agent_type END, \ updated_at = excluded.updated_at", ) .bind(params.assistant_id) .bind(params.enabled) .bind(params.sort_order) .bind(last_used_at) - .bind(pat_value) .bind(now) - .bind(pat_write) - .bind(pat_value) .execute(&self.pool) .await?; @@ -646,23 +623,17 @@ pub async fn rebuild_legacy_assistant_mirror( ("fixed", Some(model)) => serde_json::to_string(&vec![model]).unwrap_or_else(|_| "[]".to_string()), _ => "[]".to_string(), }; - let legacy_preset_agent_type = resolve_agent_binding(pool, &definition.agent_id) - .await? - .map(|resolution| resolution.runtime_backend) - .unwrap_or_else(|| definition.agent_id.clone()); - if definition.source == "user" { sqlx::query( "INSERT INTO assistants ( - id, name, description, avatar, preset_agent_type, enabled_skills, + id, name, description, avatar, enabled_skills, custom_skill_names, disabled_builtin_skills, prompts, models, name_i18n, description_i18n, prompts_i18n, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, avatar = excluded.avatar, - preset_agent_type = excluded.preset_agent_type, enabled_skills = excluded.enabled_skills, custom_skill_names = excluded.custom_skill_names, disabled_builtin_skills = excluded.disabled_builtin_skills, @@ -677,7 +648,6 @@ pub async fn rebuild_legacy_assistant_mirror( .bind(&definition.name) .bind(&definition.description) .bind(&definition.avatar_value) - .bind(&legacy_preset_agent_type) .bind(&default_skills) .bind(&custom_skill_names) .bind(&disabled_builtin) @@ -699,31 +669,20 @@ pub async fn rebuild_legacy_assistant_mirror( let enabled = state.map(|row| row.enabled).unwrap_or(true); let sort_order = state.map(|row| row.sort_order).unwrap_or_default(); - let agent_id_override = match state.and_then(|row| row.agent_id_override.as_deref()) { - Some(agent_id) => Some( - resolve_agent_binding(pool, agent_id) - .await? - .map(|resolution| resolution.runtime_backend) - .unwrap_or_else(|| agent_id.to_owned()), - ), - None => None, - }; let last_used_at = state.and_then(|row| row.last_used_at); sqlx::query( - "INSERT INTO assistant_overrides (assistant_id, enabled, sort_order, preset_agent_type, last_used_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) + "INSERT INTO assistant_overrides (assistant_id, enabled, sort_order, last_used_at, updated_at) + VALUES (?, ?, ?, ?, ?) ON CONFLICT(assistant_id) DO UPDATE SET enabled = excluded.enabled, sort_order = excluded.sort_order, - preset_agent_type = excluded.preset_agent_type, last_used_at = excluded.last_used_at, updated_at = excluded.updated_at", ) .bind(&definition.assistant_id) .bind(enabled) .bind(sort_order) - .bind(agent_id_override) .bind(last_used_at) .bind(state.map(|row| row.updated_at).unwrap_or(definition.updated_at)) .execute(pool) @@ -778,7 +737,6 @@ mod tests { name, description: Some("desc"), avatar: None, - preset_agent_type: "gemini", enabled_skills: Some(r#"["skill-a"]"#), custom_skill_names: None, disabled_builtin_skills: None, @@ -836,7 +794,6 @@ mod tests { let row = a.create(¶ms("u1", "User One")).await.unwrap(); assert_eq!(row.id, "u1"); assert_eq!(row.name, "User One"); - assert_eq!(row.preset_agent_type, "gemini"); assert_eq!(row.enabled_skills.as_deref(), Some(r#"["skill-a"]"#)); assert!(row.created_at > 0); assert_eq!(row.created_at, row.updated_at); @@ -883,7 +840,6 @@ mod tests { }; let updated = a.update("u1", &upd).await.unwrap().unwrap(); assert_eq!(updated.name, "renamed"); - assert_eq!(updated.preset_agent_type, "gemini"); assert_eq!(updated.description.as_deref(), Some("desc")); assert_eq!(updated.enabled_skills.as_deref(), Some(r#"["skill-a"]"#)); assert!(updated.updated_at >= updated.created_at); @@ -939,10 +895,10 @@ mod tests { assert_eq!(first.name, "first"); let mut p = params("u1", "second"); - p.preset_agent_type = "claude"; + p.description = Some("updated"); let second = a.upsert(&p).await.unwrap(); assert_eq!(second.name, "second"); - assert_eq!(second.preset_agent_type, "claude"); + assert_eq!(second.description.as_deref(), Some("updated")); let list = a.list().await.unwrap(); assert_eq!(list.len(), 1); @@ -1166,7 +1122,6 @@ mod tests { .fetch_one(db.pool()) .await .unwrap(); - assert_eq!(legacy_assistant.preset_agent_type, "gemini"); assert_eq!(legacy_assistant.avatar.as_deref(), Some("🤖")); assert_eq!(legacy_assistant.enabled_skills.as_deref(), Some(r#"["pdf","cron"]"#)); @@ -1181,7 +1136,7 @@ mod tests { } #[tokio::test] - async fn rebuild_legacy_mirror_writes_runtime_backend_from_agent_id() { + async fn rebuild_legacy_mirror_omits_runtime_backend_columns() { let (d, s, _p, db) = setup_v2().await; let mut params = definition_params("u2", "User Two"); params.id = "asstdef_u2"; @@ -1206,14 +1161,14 @@ mod tests { .fetch_one(db.pool()) .await .unwrap(); - assert_eq!(legacy_assistant.preset_agent_type, "gemini"); + assert_eq!(legacy_assistant.name, "User Two"); let legacy_override = sqlx::query_as::<_, AssistantOverrideRow>("SELECT * FROM assistant_overrides WHERE assistant_id = 'u2'") .fetch_one(db.pool()) .await .unwrap(); - assert_eq!(legacy_override.preset_agent_type.as_deref(), Some("claude")); + assert!(legacy_override.enabled); } #[tokio::test] @@ -1283,6 +1238,5 @@ mod tests { .unwrap(); assert!(!legacy_override.enabled); assert_eq!(legacy_override.sort_order, 3); - assert_eq!(legacy_override.preset_agent_type.as_deref(), Some("claude")); } } diff --git a/crates/aionui-db/tests/cron_assistant_first_migration.rs b/crates/aionui-db/tests/cron_assistant_first_migration.rs index 3b07739ad..bc8a7c134 100644 --- a/crates/aionui-db/tests/cron_assistant_first_migration.rs +++ b/crates/aionui-db/tests/cron_assistant_first_migration.rs @@ -149,6 +149,15 @@ async fn seed_legacy_assistant_identity(pool: &sqlx::SqlitePool) { .execute(pool) .await .unwrap(); + + sqlx::query( + "INSERT INTO acp_session ( + conversation_id, agent_backend, agent_source, agent_id, session_id, session_status, session_config + ) VALUES ('conv_snapshot', 'claude', 'builtin', '', 'session-1', 'suspended', '{}')", + ) + .execute(pool) + .await + .unwrap(); } async fn insert_legacy_cron( @@ -228,6 +237,18 @@ async fn migration_013_normalizes_legacy_cron_agent_identity() { .unwrap(); assert!(!cron_columns.iter().any(|column| column == "agent_type")); + let acp_session_columns: Vec = sqlx::query_scalar("SELECT name FROM pragma_table_info('acp_session')") + .fetch_all(&pool) + .await + .unwrap(); + assert!(!acp_session_columns.iter().any(|column| column == "agent_backend")); + let recovered_session_agent_id: String = + sqlx::query_scalar("SELECT agent_id FROM acp_session WHERE conversation_id = 'conv_snapshot'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(recovered_session_agent_id, "agent-claude"); + let backend_key_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM cron_jobs WHERE agent_config IS NOT NULL diff --git a/crates/aionui-extension/src/constants.rs b/crates/aionui-extension/src/constants.rs index ddef474f1..51f2d63f3 100644 --- a/crates/aionui-extension/src/constants.rs +++ b/crates/aionui-extension/src/constants.rs @@ -126,7 +126,7 @@ mod tests { } #[test] - fn test_preset_agent_types_non_empty() { + fn test_agent_ids_non_empty() { assert!(!PRESET_AGENT_TYPES.is_empty()); assert!(PRESET_AGENT_TYPES.contains(&"claude")); } diff --git a/crates/aionui-extension/src/manifest.rs b/crates/aionui-extension/src/manifest.rs index 6fdc3f118..c9576daf0 100644 --- a/crates/aionui-extension/src/manifest.rs +++ b/crates/aionui-extension/src/manifest.rs @@ -268,7 +268,7 @@ fn normalize_agent(value: &mut Value) { return; }; - move_key(obj, "presetAgentType", "agent_type"); + move_key(obj, "agentType", "agent_type"); } fn normalize_skill(value: &mut Value) { diff --git a/crates/aionui-extension/src/resolvers/assistant.rs b/crates/aionui-extension/src/resolvers/assistant.rs index 51fb10bbe..873cfba16 100644 --- a/crates/aionui-extension/src/resolvers/assistant.rs +++ b/crates/aionui-extension/src/resolvers/assistant.rs @@ -41,7 +41,7 @@ pub fn resolve_assistant( system_prompt, icon, context, - preset_agent_type: assistant.preset_agent_type.clone(), + agent_id: assistant.agent_id.clone(), enabled_skills: assistant.enabled_skills.clone(), prompts: assistant.prompts.clone(), models: assistant.models.clone(), @@ -80,7 +80,7 @@ mod tests { system_prompt: Some("You are helpful.".into()), icon: None, context: None, - preset_agent_type: None, + agent_id: None, enabled_skills: vec![], prompts: vec![], models: vec![], @@ -107,7 +107,7 @@ mod tests { system_prompt: Some("@file:prompts/system.md".into()), icon: None, context: None, - preset_agent_type: None, + agent_id: None, enabled_skills: vec![], prompts: vec![], models: vec![], @@ -128,7 +128,7 @@ mod tests { system_prompt: Some("@file:missing.md".into()), icon: None, context: None, - preset_agent_type: None, + agent_id: None, enabled_skills: vec![], prompts: vec![], models: vec![], @@ -151,7 +151,7 @@ mod tests { system_prompt: None, icon: None, context: Some("@file:context.md".into()), - preset_agent_type: None, + agent_id: None, enabled_skills: vec![], prompts: vec![], models: vec![], @@ -173,7 +173,7 @@ mod tests { system_prompt: Some("plain text".into()), icon: None, context: None, - preset_agent_type: None, + agent_id: None, enabled_skills: vec![], prompts: vec![], models: vec![], @@ -185,7 +185,7 @@ mod tests { system_prompt: Some("@file:missing.md".into()), icon: None, context: None, - preset_agent_type: None, + agent_id: None, enabled_skills: vec![], prompts: vec![], models: vec![], diff --git a/crates/aionui-extension/src/routes.rs b/crates/aionui-extension/src/routes.rs index 120afc398..3c12db7fd 100644 --- a/crates/aionui-extension/src/routes.rs +++ b/crates/aionui-extension/src/routes.rs @@ -173,7 +173,7 @@ async fn get_assistants( "name": assistant.name, "description": assistant.description, "avatar": assistant.icon, - "presetAgentType": assistant.preset_agent_type, + "agentId": assistant.agent_id, "context": assistant.context.unwrap_or_default(), "models": assistant.models, "enabledSkills": assistant.enabled_skills, @@ -245,7 +245,7 @@ async fn get_agents( "name": agent.name, "description": agent.description, "avatar": agent.icon, - "presetAgentType": agent.agent_type, + "agentType": agent.agent_type, "context": agent.context.unwrap_or_default(), "models": agent.models, "enabledSkills": agent.enabled_skills, diff --git a/crates/aionui-extension/src/types.rs b/crates/aionui-extension/src/types.rs index cd16e4388..bf9223574 100644 --- a/crates/aionui-extension/src/types.rs +++ b/crates/aionui-extension/src/types.rs @@ -146,8 +146,8 @@ pub struct ExtAssistant { pub icon: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub context: Option, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "presetAgentType")] - pub preset_agent_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none", alias = "agentId")] + pub agent_id: Option, #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "enabledSkills")] pub enabled_skills: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -537,7 +537,7 @@ pub struct ResolvedAssistant { #[serde(default, skip_serializing_if = "Option::is_none")] pub context: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub preset_agent_type: Option, + pub agent_id: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub enabled_skills: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/crates/aionui-extension/tests/contribution_resolution_test.rs b/crates/aionui-extension/tests/contribution_resolution_test.rs index 9a09d2b61..58fdeb0fe 100644 --- a/crates/aionui-extension/tests/contribution_resolution_test.rs +++ b/crates/aionui-extension/tests/contribution_resolution_test.rs @@ -175,7 +175,7 @@ fn cr3_assistant_file_reference_resolved() { system_prompt: Some("@file:prompts/system.md".into()), icon: Some("icons/code.png".into()), context: None, - preset_agent_type: Some("gemini".into()), + agent_id: Some("gemini".into()), enabled_skills: vec!["code-review".into()], prompts: vec!["Review this patch".into()], models: vec!["gemini-2.0-flash".into()], @@ -193,7 +193,7 @@ fn cr3_assistant_file_reference_resolved() { assistant.system_prompt.as_deref(), Some("You are a helpful coding assistant.") ); - assert_eq!(assistant.preset_agent_type.as_deref(), Some("gemini")); + assert_eq!(assistant.agent_id.as_deref(), Some("gemini")); assert_eq!(assistant.enabled_skills, vec!["code-review"]); assert_eq!(assistant.prompts, vec!["Review this patch"]); assert_eq!(assistant.models, vec!["gemini-2.0-flash"]); From 31158459f24be5dd9e663f28fd5975d5a5beac28 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 24 Jun 2026 10:29:57 +0800 Subject: [PATCH 130/135] refactor(assistant): expose acp backend explicitly --- crates/aionui-api-types/src/assistant.rs | 2 +- crates/aionui-app/tests/assistants_e2e.rs | 2 +- crates/aionui-assistant/src/service.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/aionui-api-types/src/assistant.rs b/crates/aionui-api-types/src/assistant.rs index b822ecf4b..8e54416c1 100644 --- a/crates/aionui-api-types/src/assistant.rs +++ b/crates/aionui-api-types/src/assistant.rs @@ -31,7 +31,7 @@ pub struct AssistantAgentResponse { pub r#type: AgentType, pub source: AgentSource, #[serde(default, skip_serializing_if = "Option::is_none")] - pub backend: Option, + pub acp_backend: Option, } /// Wire shape returned by `GET /api/assistants` (single element) and diff --git a/crates/aionui-app/tests/assistants_e2e.rs b/crates/aionui-app/tests/assistants_e2e.rs index 6e5a850cc..713c33028 100644 --- a/crates/aionui-app/tests/assistants_e2e.rs +++ b/crates/aionui-app/tests/assistants_e2e.rs @@ -599,7 +599,7 @@ async fn get_detail_returns_definition_state_preferences_and_rules() { assert_eq!(data["state"]["enabled"], false); assert_eq!(data["state"]["sort_order"], 7); assert_eq!(data["engine"]["agent_id"], "8e1acf31"); - assert_eq!(data["engine"]["agent"]["backend"], "codex"); + assert_eq!(data["engine"]["agent"]["acp_backend"], "codex"); assert_eq!(data["engine"]["agent"]["type"], "acp"); assert_eq!(data["rules"]["content"], "user rule body"); assert_eq!(data["rules"]["storage_mode"], "user_file"); diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index a69ab7b92..9cc5dfeb0 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -1984,7 +1984,7 @@ fn assistant_projection_for_definition( id: row.id.clone(), r#type: row.agent_type, source: row.agent_source, - backend: row.backend.clone(), + acp_backend: row.backend.clone(), }); let agent_status = agent_row From 2a13562bd33de65043c32ecaa58e4587d2048040 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 24 Jun 2026 12:00:48 +0800 Subject: [PATCH 131/135] refactor(assistant): remove duplicate agent id from response --- crates/aionui-api-types/src/assistant.rs | 10 ++++++++-- crates/aionui-app/tests/assistants_e2e.rs | 9 +++++++++ crates/aionui-assistant/src/service.rs | 1 - 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/aionui-api-types/src/assistant.rs b/crates/aionui-api-types/src/assistant.rs index 8e54416c1..1c82a64a2 100644 --- a/crates/aionui-api-types/src/assistant.rs +++ b/crates/aionui-api-types/src/assistant.rs @@ -26,7 +26,6 @@ pub enum AssistantSource { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AssistantAgentResponse { - pub id: String, #[serde(rename = "type")] pub r#type: AgentType, pub source: AgentSource, @@ -353,7 +352,11 @@ mod tests { enabled: true, sort_order: 5, agent_id: "agent-gemini".into(), - agent: None, + agent: Some(AssistantAgentResponse { + r#type: AgentType::Acp, + source: AgentSource::Builtin, + acp_backend: Some("gemini".into()), + }), enabled_skills: vec![], custom_skill_names: vec![], disabled_builtin_skills: vec![], @@ -373,6 +376,9 @@ mod tests { let json = serde_json::to_value(&resp).unwrap(); assert!(json.get("preset_agent_type").is_none()); assert_eq!(json["agent_id"], "agent-gemini"); + assert!(json["agent"].get("id").is_none()); + assert!(json["agent"].get("backend").is_none()); + assert_eq!(json["agent"]["acp_backend"], "gemini"); assert_eq!(json["sort_order"], 5); assert_eq!(json["last_used_at"], 1234); } diff --git a/crates/aionui-app/tests/assistants_e2e.rs b/crates/aionui-app/tests/assistants_e2e.rs index 713c33028..2a74c02ec 100644 --- a/crates/aionui-app/tests/assistants_e2e.rs +++ b/crates/aionui-app/tests/assistants_e2e.rs @@ -418,6 +418,13 @@ async fn list_populated_excludes_extension_assistants() { assert!(sources.contains(&"bare")); assert!(sources.contains(&"builtin")); assert!(!sources.contains(&"extension")); + let office = find_id(&json["data"], "builtin-office").expect("builtin-office missing from assistant list"); + assert_eq!(office["agent_id"], "8e1acf31"); + assert_eq!(office["agent"]["type"], "acp"); + assert_eq!(office["agent"]["source"], "builtin"); + assert_eq!(office["agent"]["acp_backend"], "codex"); + assert!(office["agent"].get("backend").is_none()); + assert!(office["agent"].get("id").is_none()); } #[tokio::test] @@ -600,6 +607,8 @@ async fn get_detail_returns_definition_state_preferences_and_rules() { assert_eq!(data["state"]["sort_order"], 7); assert_eq!(data["engine"]["agent_id"], "8e1acf31"); assert_eq!(data["engine"]["agent"]["acp_backend"], "codex"); + assert!(data["engine"]["agent"].get("backend").is_none()); + assert!(data["engine"]["agent"].get("id").is_none()); assert_eq!(data["engine"]["agent"]["type"], "acp"); assert_eq!(data["rules"]["content"], "user rule body"); assert_eq!(data["rules"]["storage_mode"], "user_file"); diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index 9cc5dfeb0..db02226c8 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -1981,7 +1981,6 @@ fn assistant_projection_for_definition( .map(|row| row.id.clone()) .unwrap_or_else(|| effective_agent_id.to_owned()); let agent = agent_row.map(|row| AssistantAgentResponse { - id: row.id.clone(), r#type: row.agent_type, source: row.agent_source, acp_backend: row.backend.clone(), From 69184e15663e0a5aacef08aebb71212e424e9297 Mon Sep 17 00:00:00 2001 From: zk <> Date: Wed, 24 Jun 2026 14:17:28 +0800 Subject: [PATCH 132/135] chore: apply auto-fixes (fmt + clippy) --- crates/aionui-app/src/router/state.rs | 10 ++--- crates/aionui-cron/src/service.rs | 38 ++++++++++--------- .../aionui-cron/tests/service_integration.rs | 22 +++++------ 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index f5eecc4f9..84af628de 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -13,7 +13,7 @@ use aionui_assistant::{ use aionui_auth::extract_token_from_ws_headers; use aionui_channel::ChannelRouterState; use aionui_conversation::{ConversationRouterState, ConversationService}; -use aionui_cron::{CronEventEmitter, CronRouterState}; +use aionui_cron::{CronEventEmitter, CronRouterState, service::CronServiceDeps}; use aionui_db::{ IAcpSessionRepository, IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, IAssistantOverrideRepository, IAssistantPreferenceRepository, IAssistantRepository, IConversationRepository, @@ -691,16 +691,16 @@ pub fn build_cron_state(services: &AppServices) -> CronRouterState { services.database.pool().clone(), )); let assistant_overlay_repo = Arc::new(SqliteAssistantOverlayRepository::new(services.database.pool().clone())); - let cron_service = Arc::new(aionui_cron::service::CronService::new( - cron_repo, + let cron_service = Arc::new(aionui_cron::service::CronService::new(CronServiceDeps { + repo: cron_repo, agent_metadata_repo, assistant_definition_repo, assistant_overlay_repo, scheduler, executor, emitter, - services.data_dir.clone(), - )); + data_dir: services.data_dir.clone(), + })); tick_service_ref.0.lock().unwrap().replace(cron_service.clone()); diff --git a/crates/aionui-cron/src/service.rs b/crates/aionui-cron/src/service.rs index fb69984bf..7a8e9445f 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -51,26 +51,28 @@ pub struct CronService { data_dir: PathBuf, } +pub struct CronServiceDeps { + pub repo: Arc, + pub agent_metadata_repo: Arc, + pub assistant_definition_repo: Arc, + pub assistant_overlay_repo: Arc, + pub scheduler: Arc, + pub executor: Arc, + pub emitter: CronEventEmitter, + pub data_dir: PathBuf, +} + impl CronService { - pub fn new( - repo: Arc, - agent_metadata_repo: Arc, - assistant_definition_repo: Arc, - assistant_overlay_repo: Arc, - scheduler: Arc, - executor: Arc, - emitter: CronEventEmitter, - data_dir: PathBuf, - ) -> Self { + pub fn new(deps: CronServiceDeps) -> Self { Self { - repo, - agent_metadata_repo, - assistant_definition_repo, - assistant_overlay_repo, - scheduler, - executor, - emitter, - data_dir, + repo: deps.repo, + agent_metadata_repo: deps.agent_metadata_repo, + assistant_definition_repo: deps.assistant_definition_repo, + assistant_overlay_repo: deps.assistant_overlay_repo, + scheduler: deps.scheduler, + executor: deps.executor, + emitter: deps.emitter, + data_dir: deps.data_dir, } } diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index 2a2429104..a3cba8512 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -33,7 +33,7 @@ use aionui_realtime::EventBroadcaster; use aionui_cron::events::CronEventEmitter; use aionui_cron::executor::JobExecutor; use aionui_cron::scheduler::CronScheduler; -use aionui_cron::service::CronService; +use aionui_cron::service::{CronService, CronServiceDeps}; use aionui_cron::types::JobStatus; // ── Test infrastructure ──────────────────────────────────────────── @@ -731,16 +731,16 @@ async fn setup_with_conv_repo() -> ( let scheduler = Arc::new(CronScheduler::new(Arc::new(|_| {}))); let emitter = CronEventEmitter::new(bc.clone() as Arc); - let svc = CronService::new( - cron_repo.clone(), + let svc = CronService::new(CronServiceDeps { + repo: cron_repo.clone(), agent_metadata_repo, - assistant_definition_repo.clone(), - assistant_overlay_repo.clone(), + assistant_definition_repo: assistant_definition_repo.clone(), + assistant_overlay_repo: assistant_overlay_repo.clone(), scheduler, executor, emitter, data_dir, - ); + }); seed_assistant_definition( &assistant_definition_repo, @@ -827,16 +827,16 @@ async fn setup_with_assistant_repos() -> ( let scheduler = Arc::new(CronScheduler::new(Arc::new(|_| {}))); let emitter = CronEventEmitter::new(bc.clone() as Arc); - let svc = CronService::new( - cron_repo.clone(), + let svc = CronService::new(CronServiceDeps { + repo: cron_repo.clone(), agent_metadata_repo, - assistant_definition_repo.clone(), - assistant_overlay_repo.clone(), + assistant_definition_repo: assistant_definition_repo.clone(), + assistant_overlay_repo: assistant_overlay_repo.clone(), scheduler, executor, emitter, data_dir, - ); + }); seed_assistant_definition( &assistant_definition_repo, From c634999a133c6d9b42ab85c61f3f8fe6ac769f29 Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 25 Jun 2026 16:58:47 +0800 Subject: [PATCH 133/135] fix(db): preserve assistant identity during migration --- crates/aionui-assistant/src/service.rs | 29 ++++ .../013_agent_connection_snapshot.sql | 150 +++++++++++++++++- .../tests/cron_assistant_first_migration.rs | 94 +++++++++++ 3 files changed, 271 insertions(+), 2 deletions(-) diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index db02226c8..e5d4dae77 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -2658,6 +2658,35 @@ mod tests { assert!(!bare.deletable); } + #[tokio::test] + async fn bootstrap_materializes_bare_assistant_from_available_custom_agent() { + let mut custom_row = mk_agent_row( + "custom-agent-1", + "custom", + aionui_api_types::AgentManagementStatus::Online, + ); + custom_row.name = "Custom ACP Agent".into(); + custom_row.agent_source = aionui_api_types::AgentSource::Custom; + + let fx = fixture_with_options(FixtureOpts { + agent_rows: vec![custom_row], + ..Default::default() + }) + .await; + + let list = fx.service.list().await.unwrap(); + let bare = list + .iter() + .find(|assistant| assistant.id == "bare:custom-agent-1") + .expect("available custom agent should be materialized as a bare assistant"); + assert_eq!(bare.source, AssistantSource::Bare); + assert_eq!(bare.name, "Custom ACP Agent"); + assert_eq!(bare.agent_id, "custom-agent-1"); + assert_eq!(bare.agent_status, aionui_api_types::AgentManagementStatus::Online); + assert!(bare.team_selectable); + assert!(!bare.deletable); + } + #[tokio::test] async fn bootstrap_falls_back_to_agent_type_when_backend_is_empty() { // Engines like Aion CLI carry their identity in `agent_type` and leave diff --git a/crates/aionui-db/migrations/013_agent_connection_snapshot.sql b/crates/aionui-db/migrations/013_agent_connection_snapshot.sql index 803d2df82..e485696ca 100644 --- a/crates/aionui-db/migrations/013_agent_connection_snapshot.sql +++ b/crates/aionui-db/migrations/013_agent_connection_snapshot.sql @@ -314,6 +314,126 @@ ALTER TABLE assistant_overrides RENAME TO _assistant_overrides_old; ALTER TABLE _assistant_overrides_new RENAME TO assistant_overrides; DROP TABLE IF EXISTS _assistant_overrides_old; +-- Some migration-012 databases predate generated "bare" assistant materializing. +-- Cron rows in that shape can still point at an ACP agent through the +-- conversation/acp_session runtime identity. Materialize the referenced +-- generated assistant rows before cron agent_config is rewritten so those jobs +-- keep a resolvable assistant_id instead of being disabled during migration. +INSERT OR IGNORE INTO assistant_definitions ( + id, + assistant_id, + source, + owner_type, + source_ref, + source_version, + source_hash, + name, + name_i18n, + description, + description_i18n, + avatar_type, + avatar_value, + agent_id, + rule_resource_type, + rule_resource_ref, + rule_inline_content, + recommended_prompts, + recommended_prompts_i18n, + default_model_mode, + default_model_value, + default_permission_mode, + default_permission_value, + default_skills_mode, + default_skill_ids, + custom_skill_names, + default_disabled_builtin_skill_ids, + default_mcps_mode, + default_mcp_ids, + created_at, + updated_at, + deleted_at +) +WITH referenced_agent_ids AS ( + SELECT DISTINCT agent_id + FROM ( + SELECT NULLIF(TRIM(acp_session.agent_id), '') AS agent_id + FROM cron_jobs + JOIN acp_session ON acp_session.conversation_id = cron_jobs.conversation_id + + UNION + + SELECT NULLIF(TRIM(json_extract(conversations.extra, '$.agent_id')), '') AS agent_id + FROM cron_jobs + JOIN conversations ON conversations.id = cron_jobs.conversation_id + WHERE json_valid(conversations.extra) + + UNION + + SELECT ( + SELECT am.id + FROM agent_metadata am + WHERE am.id = cron_jobs.agent_type + OR am.backend = cron_jobs.agent_type + OR (cron_jobs.agent_type != 'acp' AND am.agent_type = cron_jobs.agent_type) + ORDER BY + CASE am.agent_source + WHEN 'builtin' THEN 0 + WHEN 'internal' THEN 1 + ELSE 2 + END, + am.sort_order ASC, + am.name ASC + LIMIT 1 + ) AS agent_id + FROM cron_jobs + ) + WHERE agent_id IS NOT NULL +) +SELECT + 'asstdef_generated_' || am.id, + 'bare:' || am.id, + 'generated', + 'system', + am.id, + NULL, + NULL, + am.name, + '{}', + am.description, + '{}', + CASE + WHEN NULLIF(TRIM(COALESCE(am.icon, '')), '') IS NOT NULL THEN 'emoji' + ELSE 'none' + END, + NULLIF(TRIM(COALESCE(am.icon, '')), ''), + am.id, + 'none', + NULL, + NULL, + '[]', + '{}', + 'auto', + NULL, + 'auto', + NULL, + 'auto', + '[]', + '[]', + '[]', + 'auto', + '[]', + am.created_at, + am.updated_at, + NULL +FROM agent_metadata am +JOIN referenced_agent_ids rai ON rai.agent_id = am.id +WHERE NOT EXISTS ( + SELECT 1 + FROM assistant_definitions ad + WHERE ad.source = 'generated' + AND ad.source_ref = am.id +); + -- Cron assistant-first cleanup: -- - `cron_jobs.agent_type` is derived from assistant identity at runtime. -- - `agent_config.backend` was overloaded. For aionrs rows it held the LLM @@ -362,12 +482,25 @@ SET agent_config = json_set( FROM assistant_definitions ad WHERE ad.deleted_at IS NULL AND ad.agent_id = COALESCE( + ( + SELECT NULLIF(TRIM(s.agent_id), '') + FROM acp_session s + WHERE s.conversation_id = cron_jobs.conversation_id + LIMIT 1 + ), + ( + SELECT NULLIF(TRIM(json_extract(c.extra, '$.agent_id')), '') + FROM conversations c + WHERE c.id = cron_jobs.conversation_id + AND json_valid(c.extra) + LIMIT 1 + ), ( SELECT am.id FROM agent_metadata am WHERE am.id = cron_jobs.agent_type OR am.backend = cron_jobs.agent_type - OR am.agent_type = cron_jobs.agent_type + OR (cron_jobs.agent_type != 'acp' AND am.agent_type = cron_jobs.agent_type) ORDER BY CASE am.agent_source WHEN 'builtin' THEN 0 @@ -405,12 +538,25 @@ WHERE (agent_config IS NULL OR json_valid(agent_config)) FROM assistant_definitions ad WHERE ad.deleted_at IS NULL AND ad.agent_id = COALESCE( + ( + SELECT NULLIF(TRIM(s.agent_id), '') + FROM acp_session s + WHERE s.conversation_id = cron_jobs.conversation_id + LIMIT 1 + ), + ( + SELECT NULLIF(TRIM(json_extract(c.extra, '$.agent_id')), '') + FROM conversations c + WHERE c.id = cron_jobs.conversation_id + AND json_valid(c.extra) + LIMIT 1 + ), ( SELECT am.id FROM agent_metadata am WHERE am.id = cron_jobs.agent_type OR am.backend = cron_jobs.agent_type - OR am.agent_type = cron_jobs.agent_type + OR (cron_jobs.agent_type != 'acp' AND am.agent_type = cron_jobs.agent_type) ORDER BY CASE am.agent_source WHEN 'builtin' THEN 0 diff --git a/crates/aionui-db/tests/cron_assistant_first_migration.rs b/crates/aionui-db/tests/cron_assistant_first_migration.rs index bc8a7c134..e9de41af5 100644 --- a/crates/aionui-db/tests/cron_assistant_first_migration.rs +++ b/crates/aionui-db/tests/cron_assistant_first_migration.rs @@ -330,3 +330,97 @@ async fn migration_013_normalizes_legacy_cron_agent_identity() { .unwrap(); assert_eq!(index_count, 0); } + +#[tokio::test] +async fn migration_013_recovers_cron_assistant_from_session_agent_id_without_existing_definition() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + + run_migrations_through(&pool, 12).await; + + sqlx::query( + "INSERT INTO users (id, username, password_hash, created_at, updated_at) + VALUES ('user_1', 'user_1', '', 1, 1)", + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO agent_metadata ( + id, name, backend, command, agent_type, enabled, agent_source, sort_order, created_at, updated_at + ) VALUES ('agent-claude', 'Claude Code', 'claude', 'claude', 'acp', 1, 'builtin', 200, 1, 1)", + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO conversations (id, user_id, name, type, extra, created_at, updated_at) + VALUES ( + 'conv_session_only', 'user_1', 'Session-only cron', 'acp', + '{\"agent_id\":\"agent-claude\",\"backend\":\"claude\",\"session_mode\":\"bypassPermissions\"}', + 1, 1 + )", + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO acp_session ( + conversation_id, agent_backend, agent_source, agent_id, session_id, session_status, session_config + ) VALUES ( + 'conv_session_only', 'claude', 'builtin', 'agent-claude', 'session-1', 'idle', + '{\"current_mode_id\":\"bypassPermissions\"}' + )", + ) + .execute(&pool) + .await + .unwrap(); + + insert_legacy_cron( + &pool, + "cron_session_only", + "conv_session_only", + "claude", + r#"{"backend":"claude","name":"Claude Code","mode":"bypassPermissions"}"#, + ) + .await; + + run_migration(&pool, 13).await; + + let cron = sqlx::query( + "SELECT + enabled, + json_extract(agent_config, '$.assistant_id') AS assistant_id, + json_type(agent_config, '$.backend') AS backend_key_type, + last_status, + last_error + FROM cron_jobs + WHERE id = 'cron_session_only'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(cron.get::("enabled"), 1); + assert_eq!(cron.get::("assistant_id"), "bare:agent-claude"); + assert!(cron.get::, _>("backend_key_type").is_none()); + assert!(cron.get::, _>("last_status").is_none()); + assert!(cron.get::, _>("last_error").is_none()); + + let generated = sqlx::query( + "SELECT source, source_ref, agent_id + FROM assistant_definitions + WHERE assistant_id = 'bare:agent-claude'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(generated.get::("source"), "generated"); + assert_eq!(generated.get::("source_ref"), "agent-claude"); + assert_eq!(generated.get::("agent_id"), "agent-claude"); +} From e2ee532beb15e70a511ddda85939d6280109bc1d Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 25 Jun 2026 16:58:59 +0800 Subject: [PATCH 134/135] docs: document bedrock sso expired error --- ...bedrock-sso-expired-error-investigation.md | 328 ++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 docs/2026-06-25-claude-bedrock-sso-expired-error-investigation.md diff --git a/docs/2026-06-25-claude-bedrock-sso-expired-error-investigation.md b/docs/2026-06-25-claude-bedrock-sso-expired-error-investigation.md new file mode 100644 index 000000000..f97e06d9f --- /dev/null +++ b/docs/2026-06-25-claude-bedrock-sso-expired-error-investigation.md @@ -0,0 +1,328 @@ +# Claude Bedrock SSO Expired Error Investigation + +## TL;DR + +Conversation `5555555` did receive an error, but AionUI displayed the wrong level of detail. + +The real error was: + +```text +Internal error: API Error: Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile. +``` + +The displayed error was: + +```text +The upstream Agent failed while handling the request +Agent internal error (code -32603) +``` + +Root cause: + +1. Claude Code was using Bedrock/AWS SSO. +2. The AWS SSO token had expired. +3. Claude ACP returned that as JSON-RPC internal error `-32603`. +4. AionCore classified the error as `UNKNOWN_UPSTREAM_ERROR`, `retryable=true`. +5. The useful `aws sso login` instruction stayed only in logs and was not surfaced in the conversation. + +Correct future behavior: + +1. Detect `Token is expired` / `aws sso login` / `SSO session`. +2. Classify it as `USER_LLM_PROVIDER_AUTH_FAILED`. +3. Mark it `retryable=false`. +4. Show the actionable `aws sso login` instruction to the user. +5. Do not auto-replay the turn. + +## Context + +This note records a local investigation for the conversation named `5555555`. +The fix is intentionally deferred to a later branch. + +Local environment: + +- Data directory: `/Users/zhoukai/.aionui-dev` +- Database: `/Users/zhoukai/.aionui-dev/aionui-backend.db` +- Log file: `/Users/zhoukai/Library/Logs/AionUi-Dev/2026-06-25.log` +- Conversation id: `3c2c564b` +- Conversation name: `5555555` +- Agent: Claude Code builtin agent, `agent_metadata.id = 2d23ff1c` +- ACP session id: `fc6de19b-d25f-445e-ba7e-b6cc14b64c3d` + +## Timeline + +All timestamps are local time from `/Users/zhoukai/Library/Logs/AionUi-Dev/2026-06-25.log`. + +```text +16:33:51 Conversation 3c2c564b was created from assistant/agent Claude Code. +16:33:51 First user message "3" was persisted as msg_id=95a40709. +16:36:45 First turn was cancelled by user and completed as finish with no text. +16:43:38 Second user message "你好啊" was persisted as msg_id=0d3bf8ec. +16:43:40 ACP session/prompt started for Claude. +16:46:34 Claude process wrote the real Bedrock/AWS SSO token-expired error to stderr. +16:46:34 AionCore classified the failure as UnknownUpstreamError retryable=true. +16:46:34 AionCore deferred the clean terminal error for possible auto replay. +16:46:34 AionCore started auto replay attempt 2. +16:49:40 A generic tips/error message was persisted to the conversation. +``` + +## User Visible Symptom + +AionUI displayed an error tip in the conversation, but the message was too generic: + +```json +{ + "content": "The upstream Agent failed while handling the request", + "error": { + "code": "UNKNOWN_UPSTREAM_ERROR", + "detail": "Agent internal error (code -32603)", + "feedback_recommended": true, + "message": "The upstream Agent failed while handling the request", + "ownership": "unknown_upstream", + "resolution": { + "kind": "send_feedback", + "target": "feedback" + }, + "retryable": true + }, + "type": "error" +} +``` + +The corresponding message row: + +```sql +SELECT id, msg_id, type, status, hidden, created_at, substr(content,1,1000) +FROM messages +WHERE conversation_id = '3c2c564b' +ORDER BY created_at ASC; +``` + +Result: + +```text +95a40709|95a40709|text|finish|0|1782376431917|{"content":"3"} +0d3bf8ec|0d3bf8ec|text|finish|0|1782377018422|{"content":"你好啊"} +93c9f568||tips|error|0|1782377380583|{"content":"The upstream Agent failed while handling the request","error":{"code":"UNKNOWN_UPSTREAM_ERROR","detail":"Agent internal error (code -32603)","feedback_recommended":true,"message":"The upstream Agent failed while handling the request","ownership":"unknown_upstream","resolution":{"kind":"send_feedback","target":"feedback"},"retryable":true},"type":"error"} +``` + +This proves the UI did render the backend error. The failure is not "no error message". The failure is that the backend persisted the wrong public error payload. + +## Backend Evidence + +The real upstream error is present in the local AionUI log. + +Log file: + +```text +/Users/zhoukai/Library/Logs/AionUi-Dev/2026-06-25.log +``` + +Relevant lines: + +```text +14203:[2026-06-25 16:46:34.900] [warn] [aioncore] aionui_ai_agent::capability::cli_process::spawn_sdk: CLI process stderr pid=27935 stderr="message: \"Internal error: API Error: Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile.\"," +14206:[2026-06-25 16:46:34.900] [debug] [aioncore] send_message{conversation_id=3c2c564b msg_id=def4d5d4}: aionui_ai_agent::protocol::acp: [ACP] <- $session/prompt direction="agent_response" method="session/prompt" payload_bytes=228 payload_json=false session_id="none" +14207:[2026-06-25 16:46:34.900] [error] [aioncore] send_message{conversation_id=3c2c564b msg_id=def4d5d4}: aionui_ai_agent::manager::acp::agent: ACP send_message failed error=Internal error: API Error: Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile. ({"errorKind":"unknown"}) close_reason_summary=API Error: Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile. ({"errorKind":"unknown"}) +14208:[2026-06-25 16:46:34.901] [info] [aioncore] consume_with_send_error{conversation_id=3c2c564b msg_id=def4d5d4 turn_id=turn_25707d8e}: aionui_conversation::stream_relay: StreamRelay received terminal event event_type="Error" elapsed_ms=174570 text_len=0 error_code=Some(UnknownUpstreamError) retryable=Some(true) +14209:[2026-06-25 16:46:34.901] [info] [aioncore] consume_with_send_error{conversation_id=3c2c564b msg_id=def4d5d4 turn_id=turn_25707d8e}: aionui_conversation::stream_relay: StreamRelay deferred clean terminal error for possible auto replay event_type="Error" elapsed_ms=174570 +14210:[2026-06-25 16:46:34.901] [info] [aioncore] aionui_conversation::turn_recovery_policy: conversation turn recovery decision agent_type=Acp backend="claude" error_code=Some(UnknownUpstreamError) retryable=Some(true) lifecycle=Active already_replayed=false safe_to_auto_replay=true session_recovery_signal=None saw_visible_output=false saw_tool_or_side_effect=false persisted_assistant_output=false decision=AutoReplayOnce { reason: AgentErrorRecovery, safe_to_auto_replay: true, session_recovery_signal: None } +14211:[2026-06-25 16:46:34.901] [info] [aioncore] aionui_conversation::turn_orchestrator: conversation turn auto replay starting conversation_id=3c2c564b turn_id=turn_25707d8e attempt=1 next_attempt=2 backend="claude" error_code=Some(UnknownUpstreamError) retryable=Some(true) reason=AgentErrorRecovery +``` + +Agent health state after the failure: + +```sql +SELECT id, name, last_check_status, last_check_error_code, last_check_error_message, last_failure_at +FROM agent_metadata +WHERE id = '2d23ff1c'; +``` + +Result: + +```text +2d23ff1c|Claude Code|offline|session_send_failed|Agent internal error (code -32603)|1782377380582 +``` + +## Root Cause + +The user's local Claude Code uses Bedrock/AWS SSO authentication. The AWS SSO token had expired. + +The raw actionable error was: + +```text +Internal error: API Error: Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile. +``` + +The immediate user action is to run: + +```bash +aws sso login +``` + +with the corresponding AWS profile. + +## Why AionUI Showed A Generic Error + +The failure path currently treats the ACP `-32603` internal error as an unknown upstream failure. + +Observed classification: + +- `code = UNKNOWN_UPSTREAM_ERROR` +- `ownership = unknown_upstream` +- `detail = Agent internal error (code -32603)` +- `retryable = true` +- `resolution = send_feedback` + +That classification is wrong for this case. The raw error is a provider/authentication problem and is actionable by the user. + +The bad classification has two effects: + +1. The UI loses the actionable `aws sso login` instruction. +2. The conversation recovery policy sees `retryable=true` and auto-replays once, even though retrying cannot fix an expired SSO token. + +This is why the user saw a delayed/generic error instead of an immediate actionable auth error. + +## Likely Fix Area + +Primary module: + +```text +crates/aionui-ai-agent/src/protocol/send_error.rs +``` + +Relevant behavior to adjust: + +- ACP `AgentInternal { code: -32603, message, data }` classification. +- Provider-auth free-text heuristics. +- Retryability for auth failures. + +Secondary modules to verify: + +```text +crates/aionui-conversation/src/turn_recovery_policy.rs +crates/aionui-conversation/src/stream_relay.rs +crates/aionui-conversation/src/turn_orchestrator.rs +``` + +The recovery behavior should not auto-replay non-retryable provider auth failures. + +## Non-Goals + +Do not fix this by changing frontend copy only. + +The frontend displayed what the backend persisted: + +```text +UNKNOWN_UPSTREAM_ERROR +Agent internal error (code -32603) +``` + +The fix belongs in backend error classification first. After the backend returns a specific error payload, the frontend may improve presentation, but that is secondary. + +Do not fix this by only disabling auto replay globally. + +Auto replay is useful for real retryable ACP/session failures. The issue here is incorrect retryability. Expired AWS SSO credentials are not retryable until the user logs in again. + +## Expected Classification + +For text containing any of the following signatures: + +- `Token is expired` +- `aws sso login` +- `SSO session` + +Expected stream error: + +```text +code = USER_LLM_PROVIDER_AUTH_FAILED +ownership = user_llm_provider +retryable = false +feedback_recommended = false +resolution.kind = check_provider_credentials +``` + +The user-visible detail should include the actionable instruction where safe: + +```text +Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile. +``` + +## Expected User Experience + +When Claude Code Bedrock/AWS SSO is expired, the conversation should show an error close to: + +```text +Claude Code could not authenticate with the model provider. + +Token is expired. To refresh this SSO session run: +aws sso login +``` + +The exact copy can differ, but it must preserve the action the user can take. + +The error should not suggest feedback as the primary action. It should point the user to provider credentials / local login. + +## Suggested Regression Tests + +Add a failing test before implementation in: + +```text +crates/aionui-ai-agent/src/protocol/send_error.rs +``` + +Suggested case: + +```rust +#[test] +fn classifies_bedrock_sso_token_expired_as_provider_auth_failure() { + assert_acp_classification( + AcpError::AgentInternal { + message: "Internal error: API Error: Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile.".into(), + code: -32603, + data: Some(json!({ "errorKind": "unknown" })), + }, + AgentErrorCode::UserLlmProviderAuthFailed, + AgentErrorOwnership::UserLlmProvider, + AgentErrorResolutionKind::CheckProviderCredentials, + ); +} +``` + +Also assert: + +```rust +assert_eq!(err.stream_error().retryable, Some(false)); +assert_eq!(err.stream_error().feedback_recommended, Some(false)); +``` + +If this path currently sanitizes away the original message, decide explicitly whether the user-visible `detail` may include this text. In this specific case the text is not a secret and is directly actionable. + +## Acceptance Criteria + +Future branch should be considered fixed only if all of these are true: + +1. `Token is expired ... aws sso login ...` no longer maps to `UNKNOWN_UPSTREAM_ERROR`. +2. The stream error code is `USER_LLM_PROVIDER_AUTH_FAILED`. +3. `retryable` is `false`. +4. `feedback_recommended` is `false`. +5. The user-visible detail includes the useful `aws sso login` instruction, unless a deliberate security review decides otherwise. +6. The turn recovery policy does not auto-replay this error. +7. Existing unknown upstream errors that do not match provider/auth signatures remain classified as `UNKNOWN_UPSTREAM_ERROR`. +8. Existing provider auth tests for API key errors still pass. + +Suggested verification commands: + +```bash +cargo test -p aionui-ai-agent send_error +cargo test -p aionui-conversation turn_recovery +``` + +Run broader affected-crate tests if the implementation touches conversation recovery: + +```bash +cargo test -p aionui-ai-agent -p aionui-conversation +``` + +## Notes + +This should be fixed in a separate branch, not in the current migration/assistant-agent-id unification branch. From c29a111cdcf5af8391375147e8a6a2c1f139270f Mon Sep 17 00:00:00 2001 From: zk <> Date: Thu, 25 Jun 2026 17:12:04 +0800 Subject: [PATCH 135/135] Revert "docs: document bedrock sso expired error" This reverts commit e2ee532beb15e70a511ddda85939d6280109bc1d. --- ...bedrock-sso-expired-error-investigation.md | 328 ------------------ 1 file changed, 328 deletions(-) delete mode 100644 docs/2026-06-25-claude-bedrock-sso-expired-error-investigation.md diff --git a/docs/2026-06-25-claude-bedrock-sso-expired-error-investigation.md b/docs/2026-06-25-claude-bedrock-sso-expired-error-investigation.md deleted file mode 100644 index f97e06d9f..000000000 --- a/docs/2026-06-25-claude-bedrock-sso-expired-error-investigation.md +++ /dev/null @@ -1,328 +0,0 @@ -# Claude Bedrock SSO Expired Error Investigation - -## TL;DR - -Conversation `5555555` did receive an error, but AionUI displayed the wrong level of detail. - -The real error was: - -```text -Internal error: API Error: Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile. -``` - -The displayed error was: - -```text -The upstream Agent failed while handling the request -Agent internal error (code -32603) -``` - -Root cause: - -1. Claude Code was using Bedrock/AWS SSO. -2. The AWS SSO token had expired. -3. Claude ACP returned that as JSON-RPC internal error `-32603`. -4. AionCore classified the error as `UNKNOWN_UPSTREAM_ERROR`, `retryable=true`. -5. The useful `aws sso login` instruction stayed only in logs and was not surfaced in the conversation. - -Correct future behavior: - -1. Detect `Token is expired` / `aws sso login` / `SSO session`. -2. Classify it as `USER_LLM_PROVIDER_AUTH_FAILED`. -3. Mark it `retryable=false`. -4. Show the actionable `aws sso login` instruction to the user. -5. Do not auto-replay the turn. - -## Context - -This note records a local investigation for the conversation named `5555555`. -The fix is intentionally deferred to a later branch. - -Local environment: - -- Data directory: `/Users/zhoukai/.aionui-dev` -- Database: `/Users/zhoukai/.aionui-dev/aionui-backend.db` -- Log file: `/Users/zhoukai/Library/Logs/AionUi-Dev/2026-06-25.log` -- Conversation id: `3c2c564b` -- Conversation name: `5555555` -- Agent: Claude Code builtin agent, `agent_metadata.id = 2d23ff1c` -- ACP session id: `fc6de19b-d25f-445e-ba7e-b6cc14b64c3d` - -## Timeline - -All timestamps are local time from `/Users/zhoukai/Library/Logs/AionUi-Dev/2026-06-25.log`. - -```text -16:33:51 Conversation 3c2c564b was created from assistant/agent Claude Code. -16:33:51 First user message "3" was persisted as msg_id=95a40709. -16:36:45 First turn was cancelled by user and completed as finish with no text. -16:43:38 Second user message "你好啊" was persisted as msg_id=0d3bf8ec. -16:43:40 ACP session/prompt started for Claude. -16:46:34 Claude process wrote the real Bedrock/AWS SSO token-expired error to stderr. -16:46:34 AionCore classified the failure as UnknownUpstreamError retryable=true. -16:46:34 AionCore deferred the clean terminal error for possible auto replay. -16:46:34 AionCore started auto replay attempt 2. -16:49:40 A generic tips/error message was persisted to the conversation. -``` - -## User Visible Symptom - -AionUI displayed an error tip in the conversation, but the message was too generic: - -```json -{ - "content": "The upstream Agent failed while handling the request", - "error": { - "code": "UNKNOWN_UPSTREAM_ERROR", - "detail": "Agent internal error (code -32603)", - "feedback_recommended": true, - "message": "The upstream Agent failed while handling the request", - "ownership": "unknown_upstream", - "resolution": { - "kind": "send_feedback", - "target": "feedback" - }, - "retryable": true - }, - "type": "error" -} -``` - -The corresponding message row: - -```sql -SELECT id, msg_id, type, status, hidden, created_at, substr(content,1,1000) -FROM messages -WHERE conversation_id = '3c2c564b' -ORDER BY created_at ASC; -``` - -Result: - -```text -95a40709|95a40709|text|finish|0|1782376431917|{"content":"3"} -0d3bf8ec|0d3bf8ec|text|finish|0|1782377018422|{"content":"你好啊"} -93c9f568||tips|error|0|1782377380583|{"content":"The upstream Agent failed while handling the request","error":{"code":"UNKNOWN_UPSTREAM_ERROR","detail":"Agent internal error (code -32603)","feedback_recommended":true,"message":"The upstream Agent failed while handling the request","ownership":"unknown_upstream","resolution":{"kind":"send_feedback","target":"feedback"},"retryable":true},"type":"error"} -``` - -This proves the UI did render the backend error. The failure is not "no error message". The failure is that the backend persisted the wrong public error payload. - -## Backend Evidence - -The real upstream error is present in the local AionUI log. - -Log file: - -```text -/Users/zhoukai/Library/Logs/AionUi-Dev/2026-06-25.log -``` - -Relevant lines: - -```text -14203:[2026-06-25 16:46:34.900] [warn] [aioncore] aionui_ai_agent::capability::cli_process::spawn_sdk: CLI process stderr pid=27935 stderr="message: \"Internal error: API Error: Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile.\"," -14206:[2026-06-25 16:46:34.900] [debug] [aioncore] send_message{conversation_id=3c2c564b msg_id=def4d5d4}: aionui_ai_agent::protocol::acp: [ACP] <- $session/prompt direction="agent_response" method="session/prompt" payload_bytes=228 payload_json=false session_id="none" -14207:[2026-06-25 16:46:34.900] [error] [aioncore] send_message{conversation_id=3c2c564b msg_id=def4d5d4}: aionui_ai_agent::manager::acp::agent: ACP send_message failed error=Internal error: API Error: Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile. ({"errorKind":"unknown"}) close_reason_summary=API Error: Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile. ({"errorKind":"unknown"}) -14208:[2026-06-25 16:46:34.901] [info] [aioncore] consume_with_send_error{conversation_id=3c2c564b msg_id=def4d5d4 turn_id=turn_25707d8e}: aionui_conversation::stream_relay: StreamRelay received terminal event event_type="Error" elapsed_ms=174570 text_len=0 error_code=Some(UnknownUpstreamError) retryable=Some(true) -14209:[2026-06-25 16:46:34.901] [info] [aioncore] consume_with_send_error{conversation_id=3c2c564b msg_id=def4d5d4 turn_id=turn_25707d8e}: aionui_conversation::stream_relay: StreamRelay deferred clean terminal error for possible auto replay event_type="Error" elapsed_ms=174570 -14210:[2026-06-25 16:46:34.901] [info] [aioncore] aionui_conversation::turn_recovery_policy: conversation turn recovery decision agent_type=Acp backend="claude" error_code=Some(UnknownUpstreamError) retryable=Some(true) lifecycle=Active already_replayed=false safe_to_auto_replay=true session_recovery_signal=None saw_visible_output=false saw_tool_or_side_effect=false persisted_assistant_output=false decision=AutoReplayOnce { reason: AgentErrorRecovery, safe_to_auto_replay: true, session_recovery_signal: None } -14211:[2026-06-25 16:46:34.901] [info] [aioncore] aionui_conversation::turn_orchestrator: conversation turn auto replay starting conversation_id=3c2c564b turn_id=turn_25707d8e attempt=1 next_attempt=2 backend="claude" error_code=Some(UnknownUpstreamError) retryable=Some(true) reason=AgentErrorRecovery -``` - -Agent health state after the failure: - -```sql -SELECT id, name, last_check_status, last_check_error_code, last_check_error_message, last_failure_at -FROM agent_metadata -WHERE id = '2d23ff1c'; -``` - -Result: - -```text -2d23ff1c|Claude Code|offline|session_send_failed|Agent internal error (code -32603)|1782377380582 -``` - -## Root Cause - -The user's local Claude Code uses Bedrock/AWS SSO authentication. The AWS SSO token had expired. - -The raw actionable error was: - -```text -Internal error: API Error: Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile. -``` - -The immediate user action is to run: - -```bash -aws sso login -``` - -with the corresponding AWS profile. - -## Why AionUI Showed A Generic Error - -The failure path currently treats the ACP `-32603` internal error as an unknown upstream failure. - -Observed classification: - -- `code = UNKNOWN_UPSTREAM_ERROR` -- `ownership = unknown_upstream` -- `detail = Agent internal error (code -32603)` -- `retryable = true` -- `resolution = send_feedback` - -That classification is wrong for this case. The raw error is a provider/authentication problem and is actionable by the user. - -The bad classification has two effects: - -1. The UI loses the actionable `aws sso login` instruction. -2. The conversation recovery policy sees `retryable=true` and auto-replays once, even though retrying cannot fix an expired SSO token. - -This is why the user saw a delayed/generic error instead of an immediate actionable auth error. - -## Likely Fix Area - -Primary module: - -```text -crates/aionui-ai-agent/src/protocol/send_error.rs -``` - -Relevant behavior to adjust: - -- ACP `AgentInternal { code: -32603, message, data }` classification. -- Provider-auth free-text heuristics. -- Retryability for auth failures. - -Secondary modules to verify: - -```text -crates/aionui-conversation/src/turn_recovery_policy.rs -crates/aionui-conversation/src/stream_relay.rs -crates/aionui-conversation/src/turn_orchestrator.rs -``` - -The recovery behavior should not auto-replay non-retryable provider auth failures. - -## Non-Goals - -Do not fix this by changing frontend copy only. - -The frontend displayed what the backend persisted: - -```text -UNKNOWN_UPSTREAM_ERROR -Agent internal error (code -32603) -``` - -The fix belongs in backend error classification first. After the backend returns a specific error payload, the frontend may improve presentation, but that is secondary. - -Do not fix this by only disabling auto replay globally. - -Auto replay is useful for real retryable ACP/session failures. The issue here is incorrect retryability. Expired AWS SSO credentials are not retryable until the user logs in again. - -## Expected Classification - -For text containing any of the following signatures: - -- `Token is expired` -- `aws sso login` -- `SSO session` - -Expected stream error: - -```text -code = USER_LLM_PROVIDER_AUTH_FAILED -ownership = user_llm_provider -retryable = false -feedback_recommended = false -resolution.kind = check_provider_credentials -``` - -The user-visible detail should include the actionable instruction where safe: - -```text -Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile. -``` - -## Expected User Experience - -When Claude Code Bedrock/AWS SSO is expired, the conversation should show an error close to: - -```text -Claude Code could not authenticate with the model provider. - -Token is expired. To refresh this SSO session run: -aws sso login -``` - -The exact copy can differ, but it must preserve the action the user can take. - -The error should not suggest feedback as the primary action. It should point the user to provider credentials / local login. - -## Suggested Regression Tests - -Add a failing test before implementation in: - -```text -crates/aionui-ai-agent/src/protocol/send_error.rs -``` - -Suggested case: - -```rust -#[test] -fn classifies_bedrock_sso_token_expired_as_provider_auth_failure() { - assert_acp_classification( - AcpError::AgentInternal { - message: "Internal error: API Error: Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile.".into(), - code: -32603, - data: Some(json!({ "errorKind": "unknown" })), - }, - AgentErrorCode::UserLlmProviderAuthFailed, - AgentErrorOwnership::UserLlmProvider, - AgentErrorResolutionKind::CheckProviderCredentials, - ); -} -``` - -Also assert: - -```rust -assert_eq!(err.stream_error().retryable, Some(false)); -assert_eq!(err.stream_error().feedback_recommended, Some(false)); -``` - -If this path currently sanitizes away the original message, decide explicitly whether the user-visible `detail` may include this text. In this specific case the text is not a secret and is directly actionable. - -## Acceptance Criteria - -Future branch should be considered fixed only if all of these are true: - -1. `Token is expired ... aws sso login ...` no longer maps to `UNKNOWN_UPSTREAM_ERROR`. -2. The stream error code is `USER_LLM_PROVIDER_AUTH_FAILED`. -3. `retryable` is `false`. -4. `feedback_recommended` is `false`. -5. The user-visible detail includes the useful `aws sso login` instruction, unless a deliberate security review decides otherwise. -6. The turn recovery policy does not auto-replay this error. -7. Existing unknown upstream errors that do not match provider/auth signatures remain classified as `UNKNOWN_UPSTREAM_ERROR`. -8. Existing provider auth tests for API key errors still pass. - -Suggested verification commands: - -```bash -cargo test -p aionui-ai-agent send_error -cargo test -p aionui-conversation turn_recovery -``` - -Run broader affected-crate tests if the implementation touches conversation recovery: - -```bash -cargo test -p aionui-ai-agent -p aionui-conversation -``` - -## Notes - -This should be fixed in a separate branch, not in the current migration/assistant-agent-id unification branch.