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..2e4c7a752 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() { @@ -137,6 +143,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 +473,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/factory/acp.rs b/crates/aionui-ai-agent/src/factory/acp.rs index 6ab397256..8f7c08a39 100644 --- a/crates/aionui-ai-agent/src/factory/acp.rs +++ b/crates/aionui-ai-agent/src/factory/acp.rs @@ -677,7 +677,19 @@ 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_error_details: 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(), + has_command_override: false, + env_override_key_count: 0, }; let spec = resolve_agent_command_spec( diff --git a/crates/aionui-ai-agent/src/factory/acp_assembler.rs b/crates/aionui-ai-agent/src/factory/acp_assembler.rs index d745387a8..af8cdb8f6 100644 --- a/crates/aionui-ai-agent/src/factory/acp_assembler.rs +++ b/crates/aionui-ai-agent/src/factory/acp_assembler.rs @@ -197,11 +197,11 @@ mod tests { let prompt = result.unwrap(); assert!(prompt.contains("aion_create_team")); assert!(prompt.contains("aion_list_models")); - assert!(prompt.contains("hand off to the created Team conversation")); - assert!(!prompt.contains("Immediately")); - assert!(!prompt.contains( + assert!(prompt.contains("only use returned assistant_id values with `team_spawn_agent`")); + assert!(prompt.contains( "use team tools (`team_spawn_agent`, `team_send_message`, `team_members`, `team_task_create`, etc.) to manage your team" )); + assert!(!prompt.contains("hand off to the created Team conversation")); } #[test] @@ -247,7 +247,19 @@ mod tests { yolo_id: None, sort_order: 0, team_capable: true, + last_check_status: None, + 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, + 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/factory/aionrs.rs b/crates/aionui-ai-agent/src/factory/aionrs.rs index 1de461d5b..8f2df952c 100644 --- a/crates/aionui-ai-agent/src/factory/aionrs.rs +++ b/crates/aionui-ai-agent/src/factory/aionrs.rs @@ -1040,11 +1040,11 @@ mod tests { let prompt = overrides.system_prompt.as_deref().unwrap(); assert!(prompt.contains("aion_create_team")); assert!(prompt.contains("aion_list_models")); - assert!(prompt.contains("hand off to the created Team conversation")); - assert!(!prompt.contains("Immediately")); - assert!(!prompt.contains( + assert!(prompt.contains("only use returned assistant_id values with `team_spawn_agent`")); + assert!(prompt.contains( "use team tools (`team_spawn_agent`, `team_send_message`, `team_members`, `team_task_create`, etc.) to manage your team" )); + assert!(!prompt.contains("hand off to the created Team conversation")); } #[test] 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..db778572c 100644 --- a/crates/aionui-ai-agent/src/manager/acp/codex_sandbox.rs +++ b/crates/aionui-ai-agent/src/manager/acp/codex_sandbox.rs @@ -236,7 +236,19 @@ 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_error_details: 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(), + 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 cb46753ec..6d85e1629 100644 --- a/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs +++ b/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs @@ -71,7 +71,19 @@ 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_error_details: 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(), + has_command_override: false, + env_override_key_count: 0, } } 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-ai-agent/src/protocol/cli_detect.rs b/crates/aionui-ai-agent/src/protocol/cli_detect.rs index 4bc93d377..6cf4f5756 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 { @@ -35,6 +38,67 @@ 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::Online), + 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, + last_success_at: None, + last_failure_at: None, + handshake: AgentHandshake::default(), + has_command_override: false, + env_override_key_count: 0, + } + } + + #[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/protocol/custom_agent_probe.rs b/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs index f202d7cff..941814f6c 100644 --- a/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs +++ b/crates/aionui-ai-agent/src/protocol/custom_agent_probe.rs @@ -18,18 +18,26 @@ 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; +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 /// 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. 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. /// /// Returns `Success` only if both `which` and the ACP `initialize` @@ -55,25 +63,39 @@ 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 { - Ok(Ok(())) => TryConnectCustomAgentResponse::Success, - Ok(Err(msg)) => TryConnectCustomAgentResponse::FailAcp { error: msg }, + 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(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()), }, + }; + + // 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( - resolved: aionui_runtime::ResolvedCommand, +async fn spawn_probe_process( + resolved: ResolvedCommand, args: &[String], env: &HashMap, data_dir: &Path, -) -> Result<(), String> { +) -> Result { let mut final_args: Vec = resolved .args_prefix .iter() @@ -100,16 +122,81 @@ 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}")) +} - let (stdin, stdout) = proc - .take_stdio() - .await - .ok_or_else(|| "stdio not available after spawn_for_sdk".to_string())?; +/// RAII guard that force-kills a probe's process tree when dropped. +/// +/// Probe connections are throwaway, and some 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(); + } +} + +/// 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 }; - // Throwaway channels — we only care about init handshake succeeding. + run_handshake(&proc).await.into_response() +} + +/// 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 }, + } + } +} + +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); @@ -120,16 +207,12 @@ async fn acp_initialize( // `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}"))?; - // 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); - 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(); @@ -137,13 +220,31 @@ async fn acp_initialize( 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)] @@ -168,6 +269,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_probe_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 @@ -184,4 +360,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. 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 + /// 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"); + } } diff --git a/crates/aionui-ai-agent/src/registry.rs b/crates/aionui-ai-agent/src/registry.rs index 5835a5bc1..8e6c17cd0 100644 --- a/crates/aionui-ai-agent/src/registry.rs +++ b/crates/aionui-ai-agent/src/registry.rs @@ -17,14 +17,17 @@ 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::{ 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}; @@ -236,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 @@ -265,6 +268,59 @@ 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); + let diagnostics = derive_management_diagnostics(&meta, status); + 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: Vec::new(), + 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: 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, + 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(); + 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 +360,30 @@ 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 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 && meta.available + meta.enabled && matches!(derive_management_status(meta), AgentManagementStatus::Online) +} + +/// 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 @@ -318,6 +392,10 @@ fn is_visible(meta: &AgentMetadata) -> bool { /// 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") @@ -363,9 +441,43 @@ 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 { + "online" => Some(AgentSnapshotCheckStatus::Online), + "offline" => Some(AgentSnapshotCheckStatus::Offline), + _ => { + 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::Offline) => AgentManagementStatus::Offline, + _ => AgentManagementStatus::Online, + } +} + +struct ManagementDiagnostics { + error_code: Option, + error_message: Option, + details: 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 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)) + } 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, + 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", + 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.") + } + } +} + +/// 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" => { + "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." + } + "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.", + _ => "", + } +} + fn decode_json_field(raw: Option<&str>, field: &str) -> Option { raw.and_then(|s| match serde_json::from_str(s) { Ok(v) => Some(v), @@ -915,4 +1223,121 @@ 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"); + } + } + + #[test] + fn decode_row_applies_command_override() { + use aionui_db::AgentMetadataRow; + let row = AgentMetadataRow { + id: "test-agent".to_string(), + icon: None, + name: "Test Agent".to_string(), + name_i18n: None, + description: None, + description_i18n: None, + backend: Some("test".to_string()), + agent_type: "acp".to_string(), + agent_source: "builtin".to_string(), + agent_source_info: None, + enabled: true, + command: Some("droid".to_string()), + command_override: Some("/opt/factory/bin/droid".to_string()), + 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: 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, + env_override: None, + created_at: 0, + updated_at: 0, + }; + let (meta, _) = super::decode_row(row).expect("decodes"); + assert_eq!(meta.command.as_deref(), Some("/opt/factory/bin/droid")); + } + + #[test] + fn decode_row_appends_env_override_and_skips_blocked() { + use aionui_db::AgentMetadataRow; + let row = AgentMetadataRow { + id: "test-agent-2".to_string(), + icon: None, + name: "Test Agent 2".to_string(), + name_i18n: None, + description: None, + description_i18n: None, + backend: Some("test".to_string()), + agent_type: "acp".to_string(), + agent_source: "builtin".to_string(), + agent_source_info: None, + enabled: true, + command: Some("test-cmd".to_string()), + command_override: None, + args: None, + env: Some(r#"[{"name":"BASE","value":"seed","description":""}]"#.to_string()), + 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: 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, + env_override: Some( + r#"[{"name":"ANTHROPIC_API_KEY","value":"sk-x","description":""},{"name":"PATH","value":"/evil","description":""}]"#.to_string(), + ), + created_at: 0, + updated_at: 0, + }; + let (meta, _) = super::decode_row(row).expect("decodes"); + let names: Vec<&str> = 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"); + } } diff --git a/crates/aionui-ai-agent/src/registry_tests.rs b/crates/aionui-ai-agent/src/registry_tests.rs index 25dbabb89..afb427193 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() { @@ -28,7 +32,19 @@ 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_error_details: 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(), + has_command_override: false, + env_override_key_count: 0, }; let resolved = probe_resolved_command(&meta).expect("probe"); @@ -68,7 +84,19 @@ 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_error_details: 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(), + has_command_override: false, + env_override_key_count: 0, }; let reason = probe_resolved_command(&meta).expect_err("missing claude CLI must hide builtin row"); @@ -111,7 +139,19 @@ 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_error_details: 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(), + has_command_override: false, + env_override_key_count: 0, }; let reason = probe_resolved_command(&meta).expect_err("missing codex CLI must hide builtin row"); @@ -120,3 +160,66 @@ 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")) + ); + 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/routes/agent.rs b/crates/aionui-ai-agent/src/routes/agent.rs index 926d366ec..98db9537a 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; @@ -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, + AgentLogoEntry, AgentManagementRow, AgentMetadata, AgentOverridesResponse, ApiResponse, CustomAgentUpsertRequest, + DeleteCustomAgentResponse, ProviderHealthCheckRequest, ProviderHealthCheckResponse, SetAgentOverridesRequest, + SetEnabledRequest, TryConnectCustomAgentRequest, TryConnectCustomAgentResponse, }; use aionui_auth::CurrentUser; use aionui_common::ApiError; @@ -26,45 +26,66 @@ 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/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/{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)) .with_state(state) } -async fn list_agents( +async fn refresh_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)?, + state.service.refresh_agents().await.map_err(agent_error_to_api_error)?, ))) } -async fn refresh_agents( +async fn list_agent_logos( State(state): State, Extension(_user): Extension, -) -> Result>>, ApiError> { +) -> Result>>, ApiError> { Ok(Json(ApiResponse::ok( - state.service.refresh_agents().await.map_err(agent_error_to_api_error)?, + state + .service + .list_agent_logos() + .await + .map_err(agent_error_to_api_error)?, ))) } -async fn health_check( +async fn list_management_agents( State(state): State, Extension(_user): Extension, - body: Result, JsonRejection>, -) -> Result>, ApiError> { - let Json(req) = body.map_err(ApiError::from)?; +) -> Result>>, ApiError> { + Ok(Json(ApiResponse::ok( + state + .service + .list_management_agents() + .await + .map_err(agent_error_to_api_error)?, + ))) +} + +async fn health_check_by_id( + State(state): State, + Extension(_user): Extension, + Path(id): Path, +) -> Result>, ApiError> { Ok(Json(ApiResponse::ok( state .service - .acp_health_check(req) + .health_check_agent_by_id(&id) .await .map_err(agent_error_to_api_error)?, ))) @@ -159,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 677053564..08f58cf5a 100644 --- a/crates/aionui-ai-agent/src/services/agent.rs +++ b/crates/aionui-ai-agent/src/services/agent.rs @@ -16,12 +16,12 @@ use std::path::PathBuf; use std::sync::Arc; use aionui_api_types::{ - AcpHealthCheckRequest, AcpHealthCheckResponse, AgentMetadata, ProviderHealthCheckRequest, - ProviderHealthCheckResponse, + AgentLogoEntry, 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 +31,7 @@ pub struct AgentService { broadcaster: Arc, data_dir: PathBuf, provider_health: ProviderHealthCheckService, + availability: AgentAvailabilityService, } impl AgentService { @@ -41,12 +42,14 @@ impl AgentService { encryption_key: [u8; 32], data_dir: PathBuf, ) -> Arc { - let provider_health = ProviderHealthCheckService::new(provider_repo, encryption_key, 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, data_dir, provider_health, + availability, }) } @@ -65,20 +68,14 @@ impl AgentService { pub(crate) fn broadcaster(&self) -> &Arc { &self.broadcaster } + + pub fn availability_feedback_port(&self) -> Arc { + Arc::new(self.availability.clone()) + } } // 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 @@ -90,8 +87,44 @@ 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) + } + + /// 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(logo) = agent.icon.filter(|value| !value.is_empty()) else { + continue; + }; + // 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(key.clone()) { + entries.push(AgentLogoEntry { backend: key, logo }); + } + } + Ok(entries) + } + + 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( @@ -100,4 +133,62 @@ 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 new file mode 100644 index 000000000..09d7a275e --- /dev/null +++ b/crates/aionui-ai-agent/src/services/availability/mod.rs @@ -0,0 +1,642 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; + +use aionui_api_types::{ + AgentManagementRow, AgentMetadata, AgentSnapshotCheckKind, AgentSnapshotCheckStatus, AgentSource, + TryConnectCustomAgentResponse, +}; +use aionui_common::now_ms; +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, +}; +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}; + +#[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>; +} + +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, + // 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, +} + +impl AgentAvailabilityService { + pub fn new(registry: Arc, provider_repo: Arc, data_dir: PathBuf) -> Self { + Self { + registry, + data_dir, + provider_repo, + } + } + + 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, + &self.provider_repo, + &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: "offline", + 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 + } + + 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() + .await + .into_iter() + .find(|row| row.id == id) + } + + 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: 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 == "online" { + Some(snapshot.checked_at) + } else { + existing.last_success_at + }, + last_failure_at: if snapshot.status == "offline" { + 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, + provider_repo: &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 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::Online, None, None), + TryConnectCustomAgentResponse::FailCli { error } => ( + AgentSnapshotCheckStatus::Offline, + Some("command_not_found".to_owned()), + Some(error), + ), + TryConnectCustomAgentResponse::FailAcp { error } => ( + AgentSnapshotCheckStatus::Offline, + 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 + .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::Online, None, None), + TryConnectCustomAgentResponse::FailCli { error } => ( + AgentSnapshotCheckStatus::Offline, + Some("command_not_found".to_owned()), + Some(error), + ), + TryConnectCustomAgentResponse::FailAcp { error } => ( + AgentSnapshotCheckStatus::Offline, + 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; + if result.available { + (AgentSnapshotCheckStatus::Online, None, None) + } else { + ( + AgentSnapshotCheckStatus::Offline, + Some("health_check_failed".to_owned()), + 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) + }; + + let latency_ms = start.elapsed().as_millis() as i64; + let status = match status { + AgentSnapshotCheckStatus::Online => "online", + AgentSnapshotCheckStatus::Offline => "offline", + }; + + 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, + } +} + +/// 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, + 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_probe_command_spec(spec, data_dir), + ) + .await + { + Ok(response) => response, + Err(_) => TryConnectCustomAgentResponse::FailAcp { + error: "ACP handshake did not complete within 35s".to_owned(), + }, + } +} + +#[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 + } +} + +#[cfg(test)] +mod tests { + 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, + }; + use aionui_common::AgentType; + use aionui_db::{ + CreateProviderParams, IAgentMetadataRepository, IProviderRepository, SqliteAgentMetadataRepository, + SqliteProviderRepository, UpsertAgentMetadataParams, init_database_memory, + }; + + 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(); + 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 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", + "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::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!( + 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()); + } + + #[tokio::test] + 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-session-success", + icon: None, + name: "Session Success 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 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-success", + "session_send_failed", + "provider returned 401 invalid api key", + ) + .await + .unwrap(); + + service.record_session_success("agent-session-success").await.unwrap(); + + let row = service + .list_management_rows() + .await + .into_iter() + .find(|item| item.id == "agent-session-success") + .unwrap(); + + 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(); + 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(); + + 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_error_details: 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(), + has_command_override: false, + env_override_key_count: 0, + }; + + let snapshot = run_probe( + ®istry, + &provider_repo, + &meta, + std::env::temp_dir().as_path(), + AgentSnapshotCheckKind::Manual, + ) + .await; + + assert_eq!(snapshot.status, "offline"); + 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 + ); + } +} 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-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..01b6c4fb9 --- /dev/null +++ b/crates/aionui-ai-agent/tests/agent_availability_integration.rs @@ -0,0 +1,133 @@ +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, + agent_source_info: &'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(agent_source_info), + 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", + 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.update_availability_snapshot( + "agent-unavailable", + &UpdateAgentAvailabilitySnapshotParams { + last_check_status: Some("offline"), + 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("online"), + 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::Offline); + assert_eq!(unavailable.last_check_status, Some(AgentSnapshotCheckStatus::Offline)); + 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::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/acp.rs b/crates/aionui-api-types/src/acp.rs index c222ef8ef..122853ce2 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 { @@ -164,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. @@ -234,31 +224,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" }); @@ -351,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] diff --git a/crates/aionui-api-types/src/agent_discovery.rs b/crates/aionui-api-types/src/agent_discovery.rs index b965186bd..c02d2fe4f 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,10 +106,45 @@ pub struct AgentHandshake { pub available_commands: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentManagementStatus { + Missing, + Online, + Offline, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentSnapshotCheckStatus { + Online, + Offline, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentSnapshotCheckKind { + Startup, + Scheduled, + Manual, + 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 -/// 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, @@ -176,8 +211,100 @@ 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_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, + #[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, + + /// Internal carrier: whether the agent row has a command override set. + /// Computed in decode_row and projected to `AgentManagementRow`. + #[serde(skip)] + pub has_command_override: bool, + /// Internal carrier: count of non-blocked env override keys. + /// Computed in decode_row and projected to `AgentManagementRow`. + #[serde(skip)] + pub env_override_key_count: usize, +} + +#[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_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, + #[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 has_command_override: bool, + #[serde(default)] + pub env_override_key_count: usize, } #[cfg(test)] @@ -224,7 +351,19 @@ 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_error_details: 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(), + has_command_override: false, + env_override_key_count: 0, }; let v = serde_json::to_value(&meta).unwrap(); assert_eq!(v["id"], "abc12345"); @@ -253,8 +392,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::Offline).unwrap(); + assert_eq!(value, json!("offline")); + } } #[cfg(test)] diff --git a/crates/aionui-api-types/src/assistant.rs b/crates/aionui-api-types/src/assistant.rs index e3fa9f191..1c82a64a2 100644 --- a/crates/aionui-api-types/src/assistant.rs +++ b/crates/aionui-api-types/src/assistant.rs @@ -7,6 +7,10 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use aionui_common::AgentType; + +use crate::{AgentManagementStatus, AgentSource}; + // --------------------------------------------------------------------------- // Response + source enum // --------------------------------------------------------------------------- @@ -16,9 +20,19 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "lowercase")] pub enum AssistantSource { Builtin, + Bare, User, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssistantAgentResponse { + #[serde(rename = "type")] + pub r#type: AgentType, + pub source: AgentSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_backend: Option, +} + /// Wire shape returned by `GET /api/assistants` (single element) and /// by the single-resource CRUD handlers. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -36,7 +50,9 @@ pub struct AssistantResponse { pub avatar: Option, pub enabled: bool, pub sort_order: i32, - pub preset_agent_type: String, + pub agent_id: 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")] @@ -55,6 +71,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)] @@ -80,7 +103,9 @@ pub struct AssistantStateResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AssistantEngineResponse { - pub agent_backend: String, + pub agent_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -173,6 +198,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, @@ -198,7 +230,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)] @@ -233,7 +265,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)] @@ -301,6 +333,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\""); } @@ -317,7 +351,12 @@ mod tests { avatar: None, enabled: true, sort_order: 5, - preset_agent_type: "gemini".into(), + agent_id: "agent-gemini".into(), + 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![], @@ -327,10 +366,19 @@ mod tests { prompts_i18n: HashMap::new(), models: vec![], last_used_at: Some(1_234), + agent_status: AgentManagementStatus::Online, + agent_status_message: None, + team_selectable: true, + team_block_reason: None, + deletable: true, }; 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!(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); } @@ -341,7 +389,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()); } @@ -398,17 +446,18 @@ mod tests { "name": "X", "enabled": true, "sort_order": 7, // snake required field - "preset_agent_type": "gemini", // snake required field - "presetAgentType": "claude", // legacy camel — must be ignored + "agent_id": "agent-gemini", // snake required field + "agent_status": "online", // snake required field + "team_selectable": true, // snake required field + "deletable": true, // snake required field + "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/channel.rs b/crates/aionui-api-types/src/channel.rs index 0260533df..df84a53fd 100644 --- a/crates/aionui-api-types/src/channel.rs +++ b/crates/aionui-api-types/src/channel.rs @@ -87,6 +87,53 @@ pub struct SyncChannelSettingsRequest { pub platform: String, } +/// Assistant binding request for a channel 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)] +#[serde(deny_unknown_fields)] +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")] + 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 // --------------------------------------------------------------------------- @@ -768,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}"); + } } diff --git a/crates/aionui-api-types/src/conversation.rs b/crates/aionui-api-types/src/conversation.rs index 7d6b29227..d753a5b5e 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, @@ -137,6 +138,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`. @@ -204,6 +214,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, @@ -318,7 +330,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!( @@ -348,7 +360,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()); @@ -362,17 +374,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] @@ -450,7 +474,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 ────────────────────────────────────── @@ -547,6 +571,7 @@ mod tests { pinned: false, pinned_at: None, channel_chat_id: None, + assistant: None, created_at: 1712345678000, modified_at: 1712345678000, extra: json!({ "workspace": "/project" }), @@ -584,6 +609,7 @@ mod tests { pinned: false, pinned_at: None, channel_chat_id: None, + assistant: None, created_at: 1, modified_at: 1, extra: json!({}), @@ -615,6 +641,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!({}), @@ -698,6 +725,7 @@ mod tests { pinned: false, pinned_at: None, channel_chat_id: None, + assistant: None, created_at: 1712345678000, modified_at: 1712345678000, extra: json!({}), @@ -734,6 +762,7 @@ mod tests { pinned: false, pinned_at: None, channel_chat_id: None, + assistant: None, created_at: 9000, modified_at: 9000, extra: json!({}), @@ -804,6 +833,7 @@ mod tests { pinned: false, pinned_at: None, channel_chat_id: None, + assistant: None, created_at: 1000, modified_at: 1000, extra: json!({}), @@ -855,6 +885,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/cron.rs b/crates/aionui-api-types/src/cron.rs index 595b1646a..31c941f63 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}; // --------------------------------------------------------------------------- @@ -37,22 +37,43 @@ pub enum CronScheduleDto { // --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct CronAgentConfigDto { - pub backend: String, +pub struct CronAgentConfigReadDto { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub cli_path: Option, #[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, + pub mode: Option, + #[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, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct CronAgentConfigWriteDto { + 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 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, @@ -86,7 +107,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)] @@ -122,6 +143,7 @@ pub struct CronJobResponse { // --------------------------------------------------------------------------- #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct CreateCronJobRequest { pub name: String, #[serde(default)] @@ -134,12 +156,11 @@ pub struct CreateCronJobRequest { pub conversation_id: String, #[serde(default)] pub conversation_title: Option, - pub agent_type: String, pub created_by: String, #[serde(default)] pub execution_mode: Option, #[serde(default)] - pub agent_config: Option, + pub agent_config: Option, } #[derive(Debug, Clone, Deserialize)] @@ -157,7 +178,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)] @@ -204,6 +225,59 @@ 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!({ + "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_flag() { + let err = serde_json::from_value::(serde_json::json!({ + "name": "Helper", + "assistant_id": "assistant-1", + "is_preset": true, + })) + .expect_err("legacy preset flag must be rejected"); + + let message = err.to_string(); + assert!(message.contains("is_preset")); + } + + #[test] + 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", + })) + .expect("assistant-backed writes should not require backend"); + + assert_eq!(parsed.assistant_id.as_deref(), Some("assistant-1")); + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct CronJobRemovedPayload { pub job_id: String, @@ -365,37 +439,40 @@ mod tests { } } - // -- B. CronAgentConfigDto ------------------------------------------------ + // -- B. CronAgentConfigReadDto ------------------------------------------- #[test] fn agent_config_full() { let raw = json!({ - "backend": "acp", "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", "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: CronAgentConfigDto = serde_json::from_value(raw).unwrap(); - assert_eq!(c.backend, "acp"); + let c: CronAgentConfigReadDto = serde_json::from_value(raw).unwrap(); 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 c: CronAgentConfigDto = serde_json::from_value(raw).unwrap(); - assert_eq!(c.backend, "openai"); + let raw = json!({"name": "GPT"}); + let c: CronAgentConfigReadDto = serde_json::from_value(raw).unwrap(); assert_eq!(c.name, "GPT"); assert!(c.cli_path.is_none()); assert!(c.is_preset.is_none()); @@ -404,15 +481,15 @@ mod tests { #[test] fn agent_config_serialize_omits_none() { - let c = CronAgentConfigDto { - backend: "acp".into(), + let c = CronAgentConfigReadDto { name: "Test".into(), cli_path: None, is_preset: None, + assistant_id: None, custom_agent_id: None, - preset_agent_type: None, mode: None, model_id: None, + model: None, config_options: None, workspace: None, }; @@ -424,20 +501,24 @@ mod tests { #[test] fn agent_config_roundtrip() { - let c = CronAgentConfigDto { - backend: "acp".into(), + let c = CronAgentConfigReadDto { 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()), 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()), }; 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); } @@ -467,15 +548,15 @@ mod tests { created_by: "user".into(), created_at: 1700000000000, updated_at: 1700001000000, - agent_config: Some(CronAgentConfigDto { - backend: "acp".into(), + agent_config: Some(CronAgentConfigReadDto { name: "Claude".into(), cli_path: None, is_preset: None, + assistant_id: None, custom_agent_id: None, - preset_agent_type: None, mode: None, model_id: None, + model: None, config_options: None, workspace: None, }), @@ -576,16 +657,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": {"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, "acp"); assert_eq!(req.created_by, "user"); assert_eq!(req.execution_mode.as_deref(), Some("new_conversation")); assert!(req.agent_config.is_some()); @@ -597,7 +676,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(); @@ -615,7 +693,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(); @@ -628,7 +705,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()); @@ -639,7 +715,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()); @@ -650,21 +725,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" }); - assert!(serde_json::from_value::(raw).is_err()); + let err = serde_json::from_value::(raw).expect_err("legacy agent_type must be rejected"); + assert!(err.to_string().contains("agent_type")); } #[test] @@ -673,7 +749,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-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 3c6c0d305..10f03f8c0 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -31,24 +31,27 @@ 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::{ AcpBuildExtra, AcpModelInfo, AionrsBuildExtra, SessionMcpServer, SessionMcpTransport, SlashCommandCompletionBehavior, SlashCommandItem, }; -pub use agent_discovery::{AgentEnvEntry, AgentHandshake, AgentMetadata, AgentSource, AgentSourceInfo, BehaviorPolicy}; +pub use agent_discovery::{ + AgentEnvEntry, AgentHandshake, AgentLogoEntry, AgentManagementRow, AgentManagementStatus, AgentMetadata, + AgentSnapshotCheckKind, AgentSnapshotCheckStatus, AgentSource, AgentSourceInfo, BehaviorPolicy, +}; pub use agent_error::{ AgentErrorCode, AgentErrorOwnership, AgentErrorResolution, AgentErrorResolutionKind, AgentErrorResolutionTarget, AgentStreamErrorData, }; pub use assistant::{ - AssistantCapabilitiesResponse, AssistantDefaultListRequest, AssistantDefaultListResponse, + AssistantAgentResponse, AssistantCapabilitiesResponse, AssistantDefaultListRequest, AssistantDefaultListResponse, AssistantDefaultScalarRequest, AssistantDefaultScalarResponse, AssistantDefaultsRequest, AssistantDefaultsResponse, AssistantDetailResponse, AssistantEngineResponse, AssistantPreferencesResponse, AssistantProfileResponse, AssistantPromptsResponse, AssistantResponse, AssistantRulesResponse, AssistantSource, AssistantStateResponse, @@ -61,10 +64,11 @@ pub use auth::{ WebuiChangeUsernameResponse, WebuiGenerateQrTokenResponse, WebuiResetPasswordResponse, WsTokenResponse, }; pub use channel::{ - ApprovePairingRequest, BridgeResponse, 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; @@ -72,19 +76,20 @@ 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, CronJobExecutedEvent, CronJobMetadataDto, CronJobPayloadDto, - CronJobRemovedPayload, CronJobResponse, CronJobStateDto, CronJobTargetDto, CronScheduleDto, HasSkillResponse, - ListCronJobsQuery, RunNowResponse, SaveCronSkillRequest, UpdateCronJobRequest, + CreateCronJobRequest, CronAgentConfigReadDto, CronAgentConfigWriteDto, CronJobExecutedEvent, CronJobMetadataDto, + CronJobPayloadDto, CronJobRemovedPayload, CronJobResponse, CronJobStateDto, CronJobTargetDto, CronScheduleDto, + 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-api-types/src/team.rs b/crates/aionui-api-types/src/team.rs index a59b5686f..1f70b5d36 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; @@ -14,21 +14,57 @@ 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)] +#[derive(Debug, Clone)] pub struct TeamAgentInput { pub name: String, pub role: String, - pub backend: String, + pub backend: Option, pub model: String, - #[serde(default)] - pub custom_agent_id: Option, + 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 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: None, + 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. @@ -36,6 +72,7 @@ pub struct TeamAgentInput { #[derive(Debug, Deserialize)] pub struct CreateTeamRequest { pub name: String, + #[serde(alias = "assistants")] pub agents: Vec, #[serde(default)] pub workspace: Option, @@ -55,14 +92,60 @@ 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, - pub backend: String, + pub backend: Option, pub model: String, + pub assistant_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct AddAgentRequestCompat { + #[serde(default)] + assistant: Option, + #[serde(default)] + name: Option, + #[serde(default)] + role: Option, + #[serde(default)] + model: Option, #[serde(default)] - pub custom_agent_id: Option, + assistant_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: None, + model: assistant.model, + assistant_id: assistant.assistant_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"))?; + let assistant_id = + normalize_assistant_id(raw.assistant_id).ok_or_else(|| serde::de::Error::missing_field("assistant_id"))?; + + Ok(Self { + name, + role, + backend: None, + model, + assistant_id: Some(assistant_id), + }) + } } /// Request body for `PATCH /api/teams/:id/agents/:slotId/name`. @@ -362,15 +445,23 @@ pub struct TeamSendMessageQueuedResponse { #[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, pub model: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub custom_agent_id: Option, + #[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 status: Option, #[serde(default)] @@ -386,9 +477,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, } @@ -417,7 +509,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. @@ -510,15 +603,14 @@ mod tests { { "name": "Lead", "role": "lead", - "backend": "acp", "model": "claude", - "custom_agent_id": "agent-x" + "assistant_id": "assistant-x" }, { "name": "Worker", "role": "teammate", - "backend": "acp", - "model": "claude" + "model": "claude", + "assistant_id": "assistant-y" } ] }); @@ -527,11 +619,32 @@ 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!(req.agents[0].backend.is_none()); 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].custom_agent_id.is_none()); + assert_eq!(req.agents[1].assistant_id.as_deref(), Some("assistant-y")); + } + + #[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] @@ -539,8 +652,8 @@ mod tests { let raw = json!({ "name": "Lead", "role": "lead", - "backend": "acp", "model": "claude", + "assistant_id": "assistant-x", "conversation_id": "existing-conv-123" }); let input: TeamAgentInput = serde_json::from_value(raw).unwrap(); @@ -548,17 +661,68 @@ mod tests { } #[test] - fn deserialize_team_agent_input_conversation_id_defaults_to_none() { + fn deserialize_team_agent_input_rejects_legacy_custom_agent_id() { let raw = json!({ "name": "Lead", "role": "lead", "backend": "acp", - "model": "claude" + "model": "claude", + "custom_agent_id": "assistant-legacy" + }); + let result = serde_json::from_value::(raw); + assert!(result.is_err()); + } + + #[test] + fn deserialize_team_agent_input_conversation_id_defaults_to_none() { + let raw = json!({ + "name": "Lead", + "role": "lead", + "model": "claude", + "assistant_id": "assistant-x" }); let input: TeamAgentInput = serde_json::from_value(raw).unwrap(); 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!(input.backend.is_none()); + 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!({ + "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": [] }); @@ -601,44 +765,103 @@ mod tests { let raw = json!({ "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, "acp"); + assert!(req.backend.is_none()); assert_eq!(req.model, "claude"); - assert!(req.custom_agent_id.is_none()); + assert_eq!(req.assistant_id.as_deref(), Some("assistant-1")); } #[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", - "backend": "acp", "model": "claude", "custom_agent_id": "custom-1" }); + let result = serde_json::from_value::(raw); + assert!(result.is_err()); + } + + #[test] + fn deserialize_add_agent_request_with_assistant_id() { + let raw = json!({ + "name": "Custom", + "role": "teammate", + "model": "claude", + "assistant_id": "assistant-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("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" }); + let raw = json!({ + "role": "teammate", + "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() { + fn deserialize_add_agent_request_requires_assistant_id() { let raw = json!({ "name": "X", "role": "teammate", "model": "claude" }); let result = serde_json::from_value::(raw); assert!(result.is_err()); } + #[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!(req.backend.is_none()); + 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" }); @@ -689,25 +912,30 @@ 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(), - custom_agent_id: Some("agent-x".into()), + assistant_id: Some("assistant-x".into()), status: Some("idle".into()), pending_confirmations: 2, }; 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"); - assert_eq!(json["custom_agent_id"], "agent-x"); + assert_eq!(json["assistant_id"], "assistant-x"); + assert!(json.get("custom_agent_id").is_none()); assert_eq!(json["status"], "idle"); assert_eq!(json["pending_confirmations"], 2); } @@ -716,13 +944,15 @@ 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(), - custom_agent_id: None, + assistant_id: None, status: None, pending_confirmations: 0, }; @@ -738,19 +968,21 @@ 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(), 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(), - custom_agent_id: None, + assistant_id: Some("assistant-x".into()), 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, }; @@ -758,11 +990,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] @@ -771,14 +1003,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 ------------------------------------------ @@ -800,25 +1032,27 @@ 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(), 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(), - custom_agent_id: None, + assistant_id: None, status: Some("idle".into()), pending_confirmations: 0, }, }; 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] @@ -851,13 +1085,15 @@ 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(), - custom_agent_id: Some("custom-1".into()), + assistant_id: Some("custom-1".into()), status: Some("working".into()), pending_confirmations: 1, }; @@ -872,33 +1108,37 @@ 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(), name: "Lead".into(), role: "lead".into(), conversation_id: "c1".into(), + assistant_backend: "acp".into(), backend: "acp".into(), icon: None, model: "claude".into(), - custom_agent_id: None, + assistant_id: None, status: None, pending_confirmations: 0, }, 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(), - custom_agent_id: Some("x".into()), + assistant_id: Some("x".into()), status: Some("idle".into()), pending_confirmations: 3, }, ], - lead_agent_id: Some("s1".into()), + leader_assistant_id: Some("s1".into()), created_at: 1000, updated_at: 2000, }; @@ -923,15 +1163,17 @@ 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(), role: "teammate".into(), conversation_id: "c3".into(), + assistant_backend: "claude".into(), backend: "claude".into(), icon: None, model: "sonnet".into(), - custom_agent_id: None, + assistant_id: None, status: None, pending_confirmations: 0, }, @@ -981,7 +1223,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); } @@ -998,7 +1240,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-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/assets/builtin-assistants/preset-id-whitelist.json b/crates/aionui-app/assets/builtin-assistants/preset-id-whitelist.json deleted file mode 100644 index 4d93821e0..000000000 --- a/crates/aionui-app/assets/builtin-assistants/preset-id-whitelist.json +++ /dev/null @@ -1,23 +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", - "aionui-assistant" -] diff --git a/crates/aionui-app/src/commands/cmd_team_guide.rs b/crates/aionui-app/src/commands/cmd_team_guide.rs index e2c480df9..fdd2db79f 100644 --- a/crates/aionui-app/src/commands/cmd_team_guide.rs +++ b/crates/aionui-app/src/commands/cmd_team_guide.rs @@ -139,9 +139,97 @@ 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)] +struct SendMessageParams { + /// Target teammate name, or "*" to broadcast to all. + to: String, + /// Message content to send. + message: String, +} + +#[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)] + assistant_id: Option, + /// Model override for the new agent. + #[serde(default)] + model: Option, +} + +#[derive(Deserialize, schemars::JsonSchema)] +struct TaskCreateParams { + /// Short task title. + subject: String, + /// Detailed task description. + #[serde(default)] + description: Option, + /// Teammate name assigned as owner. + #[serde(default)] + owner: Option, + /// Task IDs that must complete before this task can start. + #[serde(default)] + blocked_by: Option>, +} + +#[derive(Deserialize, schemars::JsonSchema)] +struct TaskUpdateParams { + /// ID of the task to update. + task_id: String, + /// New status: pending, in_progress, completed, or deleted. + #[serde(default)] + status: Option, + /// Updated task description. + #[serde(default)] + description: Option, + /// New owner teammate name. + #[serde(default)] + owner: Option, + /// Updated list of blocking task IDs. + #[serde(default)] + blocked_by: Option>, +} + +#[derive(Deserialize, schemars::JsonSchema)] +struct RenameAgentParams { + /// Slot ID of the team member to rename. + slot_id: String, + /// New display name. + new_name: String, +} + +#[derive(Deserialize, schemars::JsonSchema)] +struct ShutdownAgentParams { + /// Slot ID of the teammate to shut down. + slot_id: String, + /// Optional reason for shutdown. + #[serde(default)] + reason: Option, +} + +#[derive(Deserialize, schemars::JsonSchema)] +struct TeamListModelsParams { + /// Assistant ID to query. Shows all backends when omitted. + #[serde(default)] + assistant_id: Option, +} + +#[derive(Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +struct DescribeAssistantParams { + /// Assistant identifier to look up. + assistant_id: String, + /// Locale for the description (e.g. "en", "zh"). Default when omitted. + #[serde(default)] + locale: Option, } #[tool_router(server_handler)] @@ -164,13 +252,153 @@ 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 + } + + #[tool( + name = "team_send_message", + description = "Send a message to a teammate or broadcast to all (to=\"*\")." + )] + async fn team_send_message(&self, Parameters(params): Parameters) -> CallToolResult { + self.forward_tool( + "team_send_message", + &serde_json::json!({ + "to": params.to, + "message": params.message, + }), + ) + .await + } + + #[tool( + name = "team_spawn_agent", + 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, + "assistant_id": params.assistant_id, + "model": params.model, + }), + ) + .await + } + + #[tool(name = "team_task_create", description = "Create a new task on the team task board.")] + async fn team_task_create(&self, Parameters(params): Parameters) -> CallToolResult { + self.forward_tool( + "team_task_create", + &serde_json::json!({ + "subject": params.subject, + "description": params.description, + "owner": params.owner, + "blocked_by": params.blocked_by, + }), + ) + .await + } + + #[tool( + name = "team_task_update", + description = "Update an existing task on the team task board." + )] + async fn team_task_update(&self, Parameters(params): Parameters) -> CallToolResult { + self.forward_tool( + "team_task_update", + &serde_json::json!({ + "task_id": params.task_id, + "status": params.status, + "description": params.description, + "owner": params.owner, + "blocked_by": params.blocked_by, + }), + ) + .await + } + + #[tool(name = "team_task_list", description = "List all tasks on the team task board.")] + async fn team_task_list(&self) -> CallToolResult { + self.forward_tool("team_task_list", &serde_json::json!({})).await + } + + #[tool( + name = "team_members", + description = "List all team members with their roles and current status." + )] + async fn team_members(&self) -> CallToolResult { + self.forward_tool("team_members", &serde_json::json!({})).await + } + + #[tool(name = "team_rename_agent", description = "Rename a team member.")] + async fn team_rename_agent(&self, Parameters(params): Parameters) -> CallToolResult { + self.forward_tool( + "team_rename_agent", + &serde_json::json!({ + "slot_id": params.slot_id, + "new_name": params.new_name, + }), + ) + .await + } + + #[tool( + name = "team_shutdown_agent", + description = "Initiate graceful shutdown of a teammate. Leader only." + )] + async fn team_shutdown_agent(&self, Parameters(params): Parameters) -> CallToolResult { + self.forward_tool( + "team_shutdown_agent", + &serde_json::json!({ + "slot_id": params.slot_id, + "reason": params.reason, + }), + ) + .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." + )] + async fn team_list_models(&self, Parameters(params): Parameters) -> CallToolResult { + self.forward_tool( + "team_list_models", + &serde_json::json!({ + "assistant_id": params.assistant_id, + }), + ) + .await + } + + #[tool( + name = "team_describe_assistant", + 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!({ + "assistant_id": params.assistant_id, + "locale": params.locale, }), ) .await @@ -205,7 +433,7 @@ mod tests { } #[test] - fn guide_stdio_exposes_only_create_team_and_list_models() { + fn guide_stdio_exposes_guide_and_team_tools() { let router = GuideServer::tool_router(); let mut names: Vec = router .list_all() @@ -215,7 +443,21 @@ mod tests { names.sort(); assert_eq!( names, - vec!["aion_create_team".to_owned(), "aion_list_models".to_owned()] + vec![ + "aion_create_team".to_owned(), + "aion_list_models".to_owned(), + "team_describe_assistant".to_owned(), + "team_list_assistants".to_owned(), + "team_list_models".to_owned(), + "team_members".to_owned(), + "team_rename_agent".to_owned(), + "team_send_message".to_owned(), + "team_shutdown_agent".to_owned(), + "team_spawn_agent".to_owned(), + "team_task_create".to_owned(), + "team_task_list".to_owned(), + "team_task_update".to_owned(), + ] ); } @@ -244,6 +486,46 @@ 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")); + } + + #[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, @@ -410,6 +692,51 @@ mod tests { assert!(!serialized.contains("team_created")); } + #[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_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(); @@ -597,7 +924,6 @@ fn parse_tool_success_text(tool_name: &str, value: &serde_json::Value) -> Option fn is_create_team_success_body(value: &serde_json::Value) -> bool { value.get("status").and_then(|status| status.as_str()) == Some("team_created") && value.get("teamId").and_then(|team_id| team_id.as_str()).is_some() - && value.get("route").and_then(|route| route.as_str()).is_some() && value .get("next_step") .and_then(|next_step| next_step.as_str()) diff --git a/crates/aionui-app/src/commands/cmd_team_stdio.rs b/crates/aionui-app/src/commands/cmd_team_stdio.rs index 5c4aed215..4db050fb7 100644 --- a/crates/aionui-app/src/commands/cmd_team_stdio.rs +++ b/crates/aionui-app/src/commands/cmd_team_stdio.rs @@ -126,21 +126,16 @@ struct SendMessageParams { } #[derive(Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] 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). + /// Assistant identifier from the available assistants catalog. #[serde(default)] - backend: Option, + assistant_id: Option, /// Agent role (default: "teammate"). #[serde(default)] role: Option, @@ -198,15 +193,16 @@ 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)] +#[serde(deny_unknown_fields)] struct DescribeAssistantParams { - /// The preset assistant ID from the "Available Preset Assistants" catalog. - custom_agent_id: String, + /// The assistant ID from the "Available Assistants" catalog. + assistant_id: String, /// Locale for the description (e.g. "en", "zh"). Default when omitted. #[serde(default)] locale: Option, @@ -232,17 +228,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, always provide assistant_id from the available assistants catalog.\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." )] 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, }), ) @@ -315,26 +309,35 @@ 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 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 assistant backends. 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 assistant-backed teammate with a specific model\n- See all available backends and their models at once\n- Verify a model ID is valid for the backend behind a chosen assistant or fallback backend\n\nPass assistant_id to query models for 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 } #[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 } @@ -677,6 +680,46 @@ 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")); + } + + #[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" + ); + } + #[test] fn team_stdio_descriptions_match_prompt_registry() { let router = TeamStdioServer::tool_router(); @@ -846,6 +889,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( diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index 644a3c368..84af628de 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -7,11 +7,13 @@ 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}; -use aionui_cron::{CronEventEmitter, CronRouterState}; +use aionui_cron::{CronEventEmitter, CronRouterState, service::CronServiceDeps}; use aionui_db::{ IAcpSessionRepository, IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, IAssistantOverrideRepository, IAssistantPreferenceRepository, IAssistantRepository, IConversationRepository, @@ -234,6 +236,9 @@ pub async fn build_module_states( encryption_key, services.data_dir.clone(), ); + 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 +294,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())); @@ -300,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()); @@ -319,6 +337,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(), )); @@ -464,14 +485,24 @@ 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_agent_metadata_repo(Arc::new(SqliteAgentMetadataRepository::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 let action_executor = Arc::new(aionui_channel::action::ActionExecutor::new( Arc::clone(&pairing_service), Arc::clone(&session_manager), Arc::clone(&channel_settings), - "acp", )); let conv_repo: Arc = Arc::new( @@ -578,6 +609,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, @@ -611,7 +646,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()); @@ -652,13 +687,20 @@ pub fn build_cron_state(services: &AppServices) -> CronRouterState { ))); let emitter = CronEventEmitter::new(services.event_bus.clone()); - let cron_service = Arc::new(aionui_cron::service::CronService::new( - cron_repo, + 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(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-app/src/router/team_conversation_adapters.rs b/crates/aionui-app/src/router/team_conversation_adapters.rs index 2eb114056..59ed82266 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, @@ -179,7 +179,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, @@ -228,6 +232,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_id.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-app/tests/acp_e2e.rs b/crates/aionui-app/tests/acp_e2e.rs index 206cd835d..4f343c1b8 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; @@ -9,16 +9,21 @@ 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 ──────────────────────────────────────────── #[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); @@ -67,48 +72,181 @@ async fn test_custom_agent_nonexistent_command() { } #[tokio::test] -async fn health_check_returns_status() { +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 (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 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; - 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()); + 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 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; + + 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/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-unavailable-agent")) + .expect("management list should include unavailable rows"); + assert_eq!(row["status"], "online"); } #[tokio::test] -async fn health_check_unknown_backend_reports_unavailable() { +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] +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; - // 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 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/health-check", - json!({ "backend": "iFlow" }), + "/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["success"], true); - assert_eq!(body["data"]["available"], false); + assert_eq!(body["data"]["id"], "custom-missing-agent"); + assert_eq!(body["data"]["status"], "missing"); } // ── Session-bound ACP routes (no active task → 404) ────────────── diff --git a/crates/aionui-app/tests/agent_integration_e2e.rs b/crates/aionui-app/tests/agent_integration_e2e.rs index 87808b943..a0fd22e7d 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,117 @@ 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 { + .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] +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") + ); + + // 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 { + 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!( - openclaw.is_none(), - "unavailable OpenClaw ACP should be hidden from /api/agents" + 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!( - agents - .iter() - .all(|agent| agent["agent_type"].as_str() != Some("openclaw-gateway")), - "old openclaw-gateway row must remain hidden from new conversation catalog" + 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 ──────────────────────────────── @@ -524,3 +616,50 @@ 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 mbody_str = serde_json::to_string(&mbody).unwrap(); + 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["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 + 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")); +} diff --git a/crates/aionui-app/tests/assistants_e2e.rs b/crates/aionui-app/tests/assistants_e2e.rs index 9398e032f..2a74c02ec 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,6 +58,119 @@ 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, + 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 { + id: &format!("asstdef-{assistant_id}"), + assistant_id, + 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_id: source_ref, + 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 { + assistant_definition_id: &format!("asstdef-{assistant_id}"), + enabled: true, + sort_order: 5, + agent_id_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 @@ -83,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", } ] }); @@ -201,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 { @@ -233,6 +351,13 @@ async fn fixture() -> Fixture { override_repo, provider_repo, builtin, + 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(), )); @@ -281,14 +406,25 @@ 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")); + 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] @@ -315,6 +451,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; @@ -340,7 +506,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"], @@ -366,12 +532,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(), @@ -383,7 +549,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(), @@ -404,17 +570,17 @@ 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_backend_override: Some("codex"), + agent_id_override: Some("8e1acf31"), last_used_at: Some(1_725_000_001_234), }) .await .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"]"#, @@ -439,7 +605,11 @@ 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"]["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"); assert_eq!(data["defaults"]["model"]["mode"], "fixed"); @@ -450,6 +620,38 @@ 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_id"], "agent-droid"); + assert_eq!(data["engine"]["agent"], Value::Null); +} + // =========================================================================== // POST /api/assistants // =========================================================================== @@ -522,7 +724,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, @@ -569,7 +771,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, @@ -619,7 +821,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, @@ -656,7 +858,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/channel_e2e.rs b/crates/aionui-app/tests/channel_e2e.rs index 642bf8c0b..f157ab5a0 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; @@ -313,6 +316,196 @@ async fn get_sessions_empty() { // §5 Settings sync // =========================================================================== +#[tokio::test] +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; + + 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"); + // 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()); +} + +#[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", + "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", + "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", + }) + ); +} + +#[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-app/tests/conversation_e2e.rs b/crates/aionui-app/tests/conversation_e2e.rs index 36dd40a1a..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, @@ -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(), @@ -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(), @@ -188,17 +188,17 @@ 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_backend_override: Some("codex"), + agent_id_override: Some("8e1acf31"), last_used_at: None, }) .await .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"]"#, @@ -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, @@ -236,9 +234,14 @@ 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_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()); + 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!( @@ -254,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.agent_backend, "codex"); + 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 5a758c32c..cb7960619 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", + "agent_id": "2d23ff1c" + }), + 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,11 @@ 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 +249,18 @@ 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" } }); @@ -293,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())); @@ -313,15 +345,14 @@ 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(), ), conversation_id: String::new(), conversation_title: None, - agent_type: "acp".into(), created_by: "user".into(), skill_content: None, description: None, @@ -458,8 +489,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; @@ -652,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", @@ -674,15 +705,11 @@ 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": { - "backend": "codex", "name": "Cron MCP Assistant", - "is_preset": true, - "custom_agent_id": "u-fixed-mcp", - "preset_agent_type": "codex" + "assistant_id": "u-fixed-mcp" } }), &token, @@ -730,7 +757,9 @@ 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["preset_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])); assert_eq!(extra["mcp_servers"], json!(["fixed-mcp"])); assert!( @@ -746,7 +775,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()); } @@ -954,14 +983,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 +1005,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 +1026,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-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"), 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 e2281022f..8079b2aef 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", + "agent_id": "2d23ff1c" + }), + 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); @@ -33,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; @@ -41,44 +73,45 @@ 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; 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(); 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"] ); } @@ -88,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(); @@ -97,29 +130,33 @@ 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"); } -// 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; 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); 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 @@ -139,8 +176,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 +193,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 +201,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 +215,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 +282,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(); @@ -260,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(); @@ -329,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", @@ -345,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; @@ -357,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()); @@ -514,8 +560,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,14 +580,19 @@ 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(); 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 @@ -550,7 +601,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 +621,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); @@ -579,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(); @@ -594,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(); @@ -602,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 @@ -629,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", @@ -650,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", @@ -664,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"); } @@ -832,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", @@ -868,7 +924,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", @@ -947,7 +1003,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( @@ -983,21 +1039,26 @@ 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!({ "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); 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( @@ -1047,11 +1108,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-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-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/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/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..e5d4dae77 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, 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::{ @@ -19,11 +19,13 @@ 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; use tracing::{debug, warn}; +use crate::agent_catalog::AssistantAgentCatalogPort; #[cfg(test)] use crate::builtin::BuiltinAssistant; use crate::builtin::{AvatarAsset, BuiltinAssistantRegistry}; @@ -37,12 +39,13 @@ 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, 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 +59,7 @@ pub struct AssistantServiceDeps { pub override_repo: Arc, pub provider_repo: Arc, pub builtin: Arc, + pub agent_catalog: Option>, } impl AssistantService { @@ -83,6 +87,7 @@ impl AssistantService { override_repo, provider_repo, builtin, + agent_catalog, } = deps; Self { pool, @@ -93,6 +98,7 @@ impl AssistantService { override_repo, provider_repo, builtin, + agent_catalog, user_data_dir, } } @@ -104,6 +110,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(()) } @@ -126,14 +133,15 @@ 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.resolve_agent_id_for_agent_ref(&builtin.agent_ref).await?; 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), @@ -145,7 +153,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 { @@ -188,7 +196,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; @@ -199,7 +207,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}")))?; } @@ -212,14 +220,18 @@ 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(()) } 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" @@ -229,10 +241,10 @@ 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_backend_override: override_row.preset_agent_type.as_deref(), + agent_id_override: None, last_used_at: override_row.last_used_at, }) .await @@ -242,7 +254,122 @@ impl AssistantService { Ok(()) } - async fn upsert_definition_from_legacy_user_row(&self, row: &AssistantRow) -> Result<(), AssistantError> { + 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?; + 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::Online) + }) + .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_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 { + id: &definition_id, + assistant_id: &assistant_id, + 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_id: &row.id, + 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() + { + 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 { + 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, + 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, + 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. @@ -255,12 +382,21 @@ 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 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 { - 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), @@ -272,7 +408,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, @@ -307,7 +443,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 { @@ -355,8 +491,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(), @@ -368,7 +504,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(), @@ -390,7 +526,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()) @@ -409,7 +545,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 @@ -418,7 +554,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}")))?; } @@ -435,6 +571,13 @@ impl AssistantService { if self.builtin.has(id) { return AssistantSource::Builtin; } + 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, + _ => AssistantSource::User, + }; + } AssistantSource::User } @@ -446,6 +589,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() @@ -458,15 +602,19 @@ 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.id), &projections) + .await?; result.push(definition_to_response( definition, - state_map.get(&definition.definition_id), + state_map.get(&definition.id), + &projection, )?); } @@ -488,20 +636,34 @@ impl AssistantService { } pub async fn get(&self, id: &str) -> Result { - 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 projections = self.reconcile_generated_assistants().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?; + 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 { - 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 projections = self.reconcile_generated_assistants().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?; - return definition_to_detail_response(&definition, state.as_ref(), preference.as_ref(), &rules_content); + let projection = self + .project_definition(&definition, state.as_ref(), &projections) + .await?; + return definition_to_detail_response( + &definition, + state.as_ref(), + preference.as_ref(), + &rules_content, + &projection, + ); } Err(AssistantError::NotFound(format!("assistant '{id}' not found"))) @@ -511,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, @@ -520,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() @@ -535,16 +697,60 @@ 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_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 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) + } + + 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 // ----------------------------------------------------------------------- @@ -569,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(), @@ -595,9 +801,10 @@ 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_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?; } @@ -614,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. @@ -634,18 +841,13 @@ 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_key(id) + .get_by_assistant_id(id) .await? .ok_or_else(|| AssistantError::NotFound(format!("assistant '{id}' not found")))?; @@ -653,39 +855,50 @@ 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 = req.agent_id.as_deref().map(|agent_id| agent_id.trim().to_string()); + 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_backend_override) - .unwrap_or_else(|| definition.agent_backend.clone()); - let reset_model_and_permission = current_agent_backend != preset_agent_type; - self.state_repo - .upsert(&UpsertAssistantOverlayParams { - definition_id: &definition.definition_id, - enabled, - sort_order, - agent_backend_override: Some(preset_agent_type), - last_used_at, - }) - .await - .map_err(|e| AssistantError::Internal(format!("upsert assistant overlay: {e}")))?; + .and_then(|row| row.agent_id_override) + .unwrap_or_else(|| definition.agent_id.clone()); + 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 .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}")))?; return self.get(id).await; } + AssistantSource::Bare => { + return Err(AssistantError::Forbidden( + "Generated assistants cannot be edited".into(), + )); + } AssistantSource::User => {} } @@ -693,13 +906,20 @@ 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 reset_model_and_permission = req - .preset_agent_type + 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(|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 { @@ -709,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())), @@ -725,10 +944,11 @@ 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_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?; } @@ -747,7 +967,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}")))?; @@ -851,7 +1071,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}")))?; } @@ -867,7 +1087,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, @@ -885,6 +1105,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 => {} } @@ -897,18 +1120,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}"); } } @@ -925,7 +1144,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() { @@ -937,10 +1156,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 @@ -957,17 +1176,16 @@ 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 = 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()); let state = self .state_repo .upsert(&UpsertAssistantOverlayParams { - definition_id: &definition.definition_id, + assistant_definition_id: &definition.id, enabled, sort_order, - agent_backend_override, + agent_id_override: agent_id_override.as_deref(), last_used_at, }) .await @@ -989,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 @@ -1043,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) => { @@ -1065,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, @@ -1083,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(), @@ -1096,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(_)) => { @@ -1132,7 +1366,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,6 +1379,9 @@ impl AssistantService { AssistantSource::Builtin => Err(AssistantError::BadRequest( "Cannot write rule for built-in assistant".into(), )), + 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() { @@ -1163,6 +1400,9 @@ impl AssistantService { AssistantSource::Builtin => Err(AssistantError::BadRequest( "Cannot delete rule for built-in assistant".into(), )), + AssistantSource::Bare => Err(AssistantError::Forbidden( + "Cannot delete rule for generated assistant".into(), + )), AssistantSource::User => Ok(remove_assistant_md_files(&self.user_rules_dir(), id)), } } @@ -1170,7 +1410,7 @@ impl AssistantService { 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,6 +1422,9 @@ impl AssistantService { AssistantSource::Builtin => Err(AssistantError::BadRequest( "Cannot write skill for built-in assistant".into(), )), + 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() { @@ -1199,6 +1442,9 @@ impl AssistantService { AssistantSource::Builtin => Err(AssistantError::BadRequest( "Cannot delete skill for built-in assistant".into(), )), + AssistantSource::Bare => Err(AssistantError::Forbidden( + "Cannot delete skill for generated assistant".into(), + )), AssistantSource::User => Ok(remove_assistant_md_files(&self.user_skills_dir(), id)), } } @@ -1221,7 +1467,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() { @@ -1376,7 +1622,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 @@ -1385,19 +1631,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) { @@ -1472,7 +1718,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(), @@ -1534,9 +1780,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 ( @@ -1548,7 +1796,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()))?, @@ -1557,9 +1805,8 @@ 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()), + agent_id: projection.agent_id.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()))?, @@ -1569,6 +1816,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 +1829,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()))?; @@ -1597,11 +1850,17 @@ 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, _ => 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()))?, @@ -1615,9 +1874,8 @@ 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_id: projection.agent_id.clone(), + agent: projection.agent.clone(), }, rules: AssistantRulesResponse { content: if rules_content.is_empty() { @@ -1664,6 +1922,118 @@ fn definition_to_detail_response( }) } +#[derive(Debug, Clone)] +struct AssistantRuntimeProjection { + agent_id: String, + agent: Option, + 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], + resolved_runtime_backend: Option<&str>, +) -> 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_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_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) { + 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.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 agent_id = agent_row + .map(|row| row.id.clone()) + .unwrap_or_else(|| effective_agent_id.to_owned()); + let agent = agent_row.map(|row| AssistantAgentResponse { + r#type: row.agent_type, + source: row.agent_source, + acp_backend: row.backend.clone(), + }); + + 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::Offline) => 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_id, + agent, + agent_status, + agent_status_message, + team_selectable: enabled + && agent_row.is_some_and(|row| matches!(row.status, AgentManagementStatus::Online) && row.team_capable), + team_block_reason, + deletable: matches!(source, AssistantSource::User), + } +} + +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()) +} + // --------------------------------------------------------------------------- // Serialization helpers // --------------------------------------------------------------------------- @@ -1985,6 +2355,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 +2393,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 { @@ -2043,7 +2426,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, }) }) @@ -2066,6 +2449,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(), ); @@ -2111,7 +2497,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(), @@ -2122,6 +2508,51 @@ 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::Online), + 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), + last_success_at: Some(1_750_000_000_000), + last_failure_at: None, + has_command_override: false, + env_override_key_count: 0, + } + } + #[tokio::test] async fn list_empty_is_empty() { let fx = fixture().await; @@ -2150,6 +2581,247 @@ 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 { + id: "asstdef-generated", + assistant_id: "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_id: "agent-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 { + assistant_definition_id: "asstdef-generated", + enabled: true, + sort_order: 3, + agent_id_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::Online, + )], + ..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.agent_id, "agent-claude"); + assert_eq!(bare.agent_status, aionui_api_types::AgentManagementStatus::Online); + assert!(bare.team_selectable); + 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 + // `backend` empty (it is an ACP-vendor label). The bare assistant must + // 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", + aionui_api_types::AgentManagementStatus::Online, + ); + 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.agent_id, "agent-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.agent_ref = "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 { + builtins: vec![mk_builtin("builtin-office", "Office")], + agent_rows: vec![ + mk_agent_row( + "agent-claude", + "claude", + aionui_api_types::AgentManagementStatus::Online, + ), + mk_agent_row("agent-codex", "codex", aionui_api_types::AgentManagementStatus::Online), + ], + ..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::Online, + )], + ..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"); @@ -2178,13 +2850,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)); @@ -2194,27 +2871,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] @@ -2243,7 +2925,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, "{}"); @@ -2320,21 +3002,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 @@ -2344,7 +3026,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] @@ -2355,7 +3037,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(), @@ -2374,7 +3056,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"); @@ -2383,6 +3065,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::Online, + )], + ..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; @@ -2390,7 +3098,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(), @@ -2412,7 +3120,7 @@ mod tests { .update( "builtin-office", UpdateAssistantRequest { - preset_agent_type: Some("claude".into()), + agent_id: Some("2d23ff1c".into()), ..Default::default() }, ) @@ -2420,7 +3128,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"); @@ -2494,7 +3202,7 @@ mod tests { .update( "u1", UpdateAssistantRequest { - preset_agent_type: Some("codex".into()), + agent_id: Some("8e1acf31".into()), ..Default::default() }, ) @@ -2502,7 +3210,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"); @@ -2663,13 +3371,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"]"#); @@ -2734,14 +3437,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] @@ -2952,6 +3649,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::Online, + )], + ..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::Online, + )], + ..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::Online, + )], + ..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::Online, + )], + ..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(); @@ -2965,7 +3730,7 @@ mod tests { "assistants": [{ "id": "builtin-office", "name": "Office", - "preset_agent_type": "gemini", + "agent_ref": "gemini", "rule_file": "rules/office.{locale}.md", }] }); @@ -2996,6 +3761,7 @@ mod tests { override_repo: orepo, provider_repo, builtin: builtin_reg, + agent_catalog: None, }, tmp.path().to_path_buf(), ); @@ -3019,7 +3785,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: @@ -3027,52 +3793,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!( @@ -3091,7 +3857,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() @@ -3120,17 +3886,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), @@ -3147,21 +3913,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() @@ -3172,12 +3936,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 { @@ -3186,7 +3950,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/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/src/channel_settings.rs b/crates/aionui-channel/src/channel_settings.rs index ca4b5d386..051524c52 100644 --- a/crates/aionui-channel/src/channel_settings.rs +++ b/crates/aionui-channel/src/channel_settings.rs @@ -1,13 +1,19 @@ use std::sync::Arc; +use aionui_api_types::{ + ChannelAssistantSettingRequest, ChannelAssistantSettingResponse, ChannelDefaultModelSetting, + ChannelPlatformSettingsResponse, +}; use aionui_common::ProviderWithModel; -use aionui_db::IClientPreferenceRepository; +use aionui_db::{ + IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, IClientPreferenceRepository, + resolve_agent_binding_from_rows, +}; 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`. @@ -17,6 +23,9 @@ 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>, } /// Resolved agent configuration for a channel platform. @@ -39,7 +48,27 @@ pub struct ResolvedModelConfig { impl ChannelSettingsService { pub fn new(pref_repo: Arc) -> Self { - Self { pref_repo } + 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, + 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`. @@ -57,30 +86,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() { + 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); + } - 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 - }; + return Err(ChannelError::InvalidConfig(format!( + "Channel assistant binding references unresolved assistant identity: {assistant_id}" + ))); + } - debug!(platform = %platform, agent_type = %at, backend = ?backend, "resolved channel agent config (new format)"); + if let Some(at) = setting.agent_type.as_deref() { + let backend = if at == "acp" { setting.backend.clone() } else { None }; - return Ok(ResolvedAgentConfig { - agent_type: at.to_owned(), - backend, - }); - } + debug!(platform = %platform, agent_type = %at, backend = ?backend, "resolved channel agent config (new format)"); - 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 }; + return Ok(ResolvedAgentConfig { + agent_type: at.to_owned(), + backend, + }); + } - debug!(platform = %platform, agent_type = %agent_type, backend = ?backend, "resolved channel agent config (legacy format)"); + 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 }; + + 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`. @@ -111,6 +162,247 @@ 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 { + 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); + } + } + + if assistant.is_none() { + assistant = self.resolve_default_channel_assistant_setting().await?; + } + + Ok(ChannelPlatformSettingsResponse { + platform: platform.to_string(), + assistant, + default_model, + }) + } + + 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 self.resolve_default_channel_assistant_setting().await; + }; + + 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( + &self, + platform: PluginType, + assistant: &ChannelAssistantSettingRequest, + ) -> Result<(), ChannelError> { + 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(()) + } + + 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(()) + } + + 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_assistant_id(assistant_id).await? else { + return Ok(None); + }; + + let agent_id = overlay_repo + .get(&definition.id) + .await? + .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 }; + + 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?; + + 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_id)); + } + } + + Ok(None) + } + + 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) + } + } + + 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?; + + 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_id.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_id.clone())); + } + + 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(|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 + } + + 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())) + } } fn agent_key(platform: PluginType) -> String { @@ -128,6 +420,47 @@ 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(ChannelAssistantSettingResponse { + assistant_id: None, + custom_agent_id: None, + backend: Some(raw.to_owned()), + agent_type: Some(backend_to_agent_type(raw)), + name: None, + }); + } + + Some(ChannelAssistantSettingResponse { + 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 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 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". @@ -166,7 +499,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 { @@ -233,6 +570,124 @@ 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_assistant_id(&self, assistant_id: &str) -> Result, DbError> { + Ok(self.rows.iter().find(|row| row.assistant_id == assistant_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( + &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.assistant_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_id: &str, agent_id: &str) -> AssistantDefinitionRow { + AssistantDefinitionRow { + id: format!("def-{assistant_id}"), + assistant_id: assistant_id.to_owned(), + source: "generated".to_owned(), + owner_type: "system".to_owned(), + source_ref: Some(assistant_id.to_owned()), + source_version: None, + source_hash: None, + name: assistant_id.to_owned(), + name_i18n: "{}".to_owned(), + description: None, + description_i18n: "{}".to_owned(), + avatar_type: "emoji".to_owned(), + avatar_value: None, + agent_id: agent_id.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_id_override: &str) -> AssistantOverlayRow { + AssistantOverlayRow { + assistant_definition_id: definition_id.to_owned(), + enabled: true, + sort_order: 0, + agent_id_override: Some(agent_id_override.to_owned()), + last_used_at: None, + created_at: 0, + updated_at: 0, + } + } + // ── backend_to_agent_type ───────────────────────────────────────── #[test] @@ -349,6 +804,62 @@ 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.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")); + } + + #[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] @@ -385,6 +896,194 @@ 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, + &ChannelAssistantSettingRequest { + assistant_id: "assistant-1".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_trims_assistant_id_before_persisting() { + let repo = Arc::new(MockPrefRepo::new()); + let svc = ChannelSettingsService::new(repo.clone()); + + svc.set_assistant_setting( + PluginType::Lark, + &ChannelAssistantSettingRequest { + assistant_id: " legacy-custom ".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()); + } + + #[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_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![( + "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")); + } + + #[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![( + "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")); + } + + #[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![( + "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] diff --git a/crates/aionui-channel/src/message_service.rs b/crates/aionui-channel/src/message_service.rs index f76147875..37b627fbf 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; @@ -125,18 +125,38 @@ 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 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() + .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()); - let mut extra = Self::build_channel_extra(agent_config.backend.as_deref()); - let name = channel_conversation_name( - platform, - &session.agent_type, - agent_config.backend.as_deref(), - session.chat_id.as_deref(), - ); + let mut extra = Self::build_channel_extra(if assistant_id.is_some() { + None + } else { + agent_config.backend.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 { @@ -147,10 +167,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: 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-channel/src/routes.rs b/crates/aionui-channel/src/routes.rs index ebde507d3..cc190d172 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, ChannelAssistantSettingRequest, 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,62 @@ 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?; + state.session_manager.clear_all_sessions().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?; + state.session_manager.clear_all_sessions().await?; + + Ok(Json(ApiResponse::ok(BridgeResponse { + success: true, + message: Some("Default model setting updated".into()), + error: None, + }))) +} + // --------------------------------------------------------------------------- // Settings sync handler // --------------------------------------------------------------------------- diff --git a/crates/aionui-channel/tests/message_service_integration.rs b/crates/aionui-channel/tests/message_service_integration.rs index 5cb32cfd8..ed092bcb1 100644 --- a/crates/aionui-channel/tests/message_service_integration.rs +++ b/crates/aionui-channel/tests/message_service_integration.rs @@ -6,14 +6,18 @@ 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}; 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, + IAcpSessionRepository, IAssistantDefinitionRepository, IClientPreferenceRepository, IConversationRepository, + SqliteAcpSessionRepository, SqliteAgentMetadataRepository, SqliteAssistantDefinitionRepository, + SqliteAssistantOverlayRepository, SqliteAssistantPreferenceRepository, SqliteClientPreferenceRepository, SqliteConversationRepository, init_database_memory, }; use aionui_realtime::EventBroadcaster; @@ -177,6 +181,44 @@ impl IWorkerTaskManager for RecordingTaskManager { } } +fn bare_assistant_definition_params<'a>( + definition_id: &'a str, + assistant_id: &'a str, + agent_id: &'a str, +) -> UpsertAssistantDefinitionParams<'a> { + UpsertAssistantDefinitionParams { + id: definition_id, + assistant_id, + source: "generated", + owner_type: "system", + source_ref: Some(assistant_id), + source_version: None, + source_hash: None, + name: assistant_id, + name_i18n: "{}", + description: Some("Channel bare assistant"), + description_i18n: "{}", + avatar_type: "emoji", + avatar_value: Some("🤖"), + agent_id, + 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 +271,289 @@ 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 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.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: "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 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(); + let conversation = conversation_repo.get(&result.conversation_id).await.unwrap().unwrap(); + assert_eq!(conversation.r#type, AgentType::Acp.serde_name()); + 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_id, "2d23ff1c"); + assert_eq!(snapshot.assistant_id, "bare-claude"); + assert_eq!(snapshot.agent_id, "2d23ff1c"); + 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_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_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"); +} + +#[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"); +} 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..5700a4965 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); @@ -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 ─────────────── 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 7e8e8519e..ac02bae19 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,31 @@ 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, + runtime_backend: &str, +) -> ConversationAssistantIdentityResponse { + let avatar = match snapshot.assistant_avatar_type.as_str() { + "builtin_asset" | "user_asset" => format!("/api/assistants/{}/avatar", snapshot.assistant_id), + _ => snapshot.assistant_avatar_value.clone().unwrap_or_default(), + }; + + ConversationAssistantIdentityResponse { + id: snapshot.assistant_id.clone(), + source: snapshot.assistant_source.clone(), + name: snapshot.assistant_name.clone(), + avatar, + backend: runtime_backend.to_owned(), + } +} + /// 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 fef743567..d364206e1 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::message_cursor::{decode_message_cursor, encode_message_cursor}; use crate::response_middleware::ICronService; @@ -27,10 +27,11 @@ 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, MessagePageCursor, MessagePageDirection, MessagePageParams, SaveRuntimeStateParams, UpsertConversationAssistantSnapshotParams, + resolve_agent_binding_from_rows, }; use aionui_extension::AssistantRuleDispatcher; use aionui_mcp::{AcpMcpCapabilities, parse_acp_mcp_capabilities}; @@ -42,7 +43,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; @@ -55,6 +57,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 { @@ -124,7 +146,12 @@ struct AssistantSnapshot { avatar_type: String, #[serde(default)] avatar: 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, @@ -132,6 +159,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, @@ -162,6 +196,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.runtime_backend.trim())?; + if let Some(explicit) = explicit_type + && explicit != derived + { + warn!( + explicit_type = explicit.serde_name(), + derived_type = derived.serde_name(), + backend = snapshot.runtime_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, @@ -249,6 +328,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. @@ -315,6 +395,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, @@ -374,6 +455,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 @@ -447,6 +534,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()) } @@ -517,11 +611,28 @@ 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? { + 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(()) + } + /// Create a new conversation. /// /// 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, @@ -531,14 +642,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(), }); } @@ -546,21 +686,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() { @@ -583,12 +721,30 @@ impl ConversationService { extra["workspace"] = serde_json::Value::String(workspace.clone()); } + let assistant_backend = assistant_snapshot + .as_ref() + .map(|snapshot| snapshot.runtime_backend.clone()) + .filter(|backend| !backend.is_empty()); + 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 // `{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, + effective_backend + .as_ref() + .map(|backend| serde_json::Value::String(backend.clone())) + .as_ref(), + ); let ws_path = self .workspace_root .join("conversations") @@ -607,59 +763,55 @@ 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() { - obj.insert( - "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.rules.content.is_empty() { + // 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.runtime_backend.is_empty() { obj.insert( - "preset_context".to_owned(), - serde_json::Value::String(snapshot.rules.content.clone()), + "backend".to_owned(), + serde_json::Value::String(snapshot.runtime_backend.clone()), ); + } + if !snapshot.agent_id.is_empty() { obj.insert( - "preset_rules".to_owned(), - serde_json::Value::String(snapshot.rules.content.clone()), + "agent_id".to_owned(), + serde_json::Value::String(snapshot.agent_id.clone()), ); + } else { + obj.remove("agent_id"); } - if let Some(model_id) = snapshot.resolved_defaults.model.as_ref() - && !obj.contains_key("current_model_id") - { + if !snapshot.agent_source.is_empty() { + obj.insert( + "agent_source".to_owned(), + serde_json::Value::String(snapshot.agent_source.clone()), + ); + } else { + obj.remove("agent_source"); + } + 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() { + 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()), + ); + } + } } // Consume transient skill-shaping inputs and freeze the initial @@ -718,8 +870,15 @@ impl ConversationService { .or_else(|| auto_provisioned_workspace.clone()); if let Some(ws_path) = skill_link_workspace.as_ref() && !initial_skills.is_empty() - && let Some(rel_dirs) = - native_skills_dirs(&self.agent_metadata_repo, &req.r#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() { @@ -771,7 +930,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(); @@ -856,7 +1015,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 @@ -893,12 +1052,12 @@ 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, 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(), @@ -917,15 +1076,28 @@ 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 { - self.create_acp_session_row(&id, &extra).await?; + if effective_type == AgentType::Acp { + self.create_acp_session_row(&id, &extra, assistant_snapshot.as_ref()) + .await?; } if let Some(snapshot) = assistant_snapshot.as_ref() { 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.runtime_backend.clone(), + }); + } self.broadcast_list_changed(&response.id, "created", response.source.as_ref()); @@ -939,6 +1111,7 @@ impl ConversationService { &self, conversation_id: &str, extra: &serde_json::Value, + assistant_snapshot: Option<&AssistantSnapshot>, ) -> Result<(), ConversationError> { debug!("Creating acp_session row"); @@ -948,8 +1121,21 @@ 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 = assistant_snapshot + .map(|snapshot| snapshot.runtime_backend.as_str()) + .filter(|value| !value.is_empty()) + .or_else(|| { + extra + .get("backend") + .and_then(|v| v.as_str()) + .filter(|value| !value.is_empty()) + }) + .unwrap_or_default(); + let agent_source = assistant_snapshot + .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"); // Fallback: older clients (electron main, legacy webhooks) only // post `backend` without `agent_id`. Resolve the builtin row for @@ -957,7 +1143,11 @@ 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 assistant_snapshot + .map(|snapshot| snapshot.agent_id.as_str()) + .filter(|id| !id.is_empty()) + .or(agent_id_from_extra) + { Some(id) => id.to_owned(), None if !backend.is_empty() && agent_source == "builtin" => self .agent_metadata_repo @@ -971,7 +1161,6 @@ impl ConversationService { let params = CreateAcpSessionParams { conversation_id, - agent_backend: backend, agent_source, agent_id: &resolved_agent_id, }; @@ -1007,6 +1196,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, @@ -1023,7 +1224,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 { @@ -1031,11 +1232,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}")))?; @@ -1109,19 +1310,30 @@ 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()); + .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, + assistant_definition_id: definition.id, assistant_id: assistant_id.to_owned(), assistant_source: definition.source, name: definition.name, avatar_type: definition.avatar_type, avatar: definition.avatar_value, - 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() @@ -1200,7 +1412,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, @@ -1235,12 +1447,12 @@ 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, 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()), @@ -1318,7 +1530,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 { @@ -1342,7 +1554,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)) @@ -1380,7 +1592,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 @@ -1419,6 +1631,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) } @@ -1460,7 +1673,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), @@ -1794,9 +2010,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. @@ -1806,9 +2026,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) } } @@ -3415,6 +3639,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 bbfcf1c29..a8900deb6 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -10,7 +10,9 @@ use aionui_ai_agent::agent_task::{AgentInstance, IAgentTask, IMockAgent}; use aionui_ai_agent::protocol::events::tool_call::{ToolCallEventData, ToolCallStatus}; use aionui_ai_agent::protocol::events::{AgentStreamEvent, ErrorEventData, FinishEventData, TextEventData}; use aionui_ai_agent::types::{BuildTaskOptions, SendMessageData}; -use aionui_ai_agent::{AcpError, AgentError, AgentSendError, AgentSessionKind, IWorkerTaskManager}; +use aionui_ai_agent::{ + AcpError, AgentAvailabilityFeedbackPort, AgentError, AgentSendError, AgentSessionKind, IWorkerTaskManager, +}; use crate::response_middleware::{CronCommandResult, CronCreateParams, CronUpdateParams, ICronService}; use aionui_api_types::{ @@ -35,8 +37,9 @@ use aionui_db::{ IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, IAssistantPreferenceRepository, IConversationRepository, MessageRowUpdate, MessageSearchRow, PersistedSessionState, SaveRuntimeStateParams, SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, - SqliteAssistantPreferenceRepository, UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, - UpsertAssistantPreferenceParams, UpsertConversationAssistantSnapshotParams, init_database_memory, + SqliteAssistantPreferenceRepository, UpdateAgentAvailabilitySnapshotParams, UpsertAssistantDefinitionParams, + UpsertAssistantOverlayParams, UpsertAssistantPreferenceParams, UpsertConversationAssistantSnapshotParams, + init_database_memory, }; use aionui_db::{MessagePageCursor, MessagePageDirection, MessagePageParams, MessagePageResult}; use aionui_extension::{AssistantRuleDispatcher, ExtensionError}; @@ -166,6 +169,36 @@ impl EventBroadcaster for MockBroadcaster { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct RecordedAvailabilityFailure { + agent_id: String, + code: String, + message: String, +} + +#[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(), + code: code.to_owned(), + message: message.to_owned(), + }); + Ok(()) + } +} + // ── Mock Repository ──────────────────────────────────────────────── struct MockRepo { @@ -326,12 +359,12 @@ 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(), 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), @@ -616,17 +649,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, @@ -635,8 +719,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())) @@ -648,6 +734,21 @@ impl IAgentMetadataRepository for StubAgentMetadataRepo { ) -> Result, DbError> { Ok(None) } + async fn update_availability_snapshot( + &self, + _id: &str, + _params: &UpdateAgentAvailabilitySnapshotParams<'_>, + ) -> 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) } @@ -686,6 +787,17 @@ fn claude_metadata_row() -> AgentMetadataRow { 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, + command_override: None, + env_override: None, created_at: 1, updated_at: 1, } @@ -719,6 +831,21 @@ impl IAgentMetadataRepository for ClaudeNativeSkillMetadataRepo { ) -> Result, DbError> { Ok(None) } + async fn update_availability_snapshot( + &self, + _id: &str, + _params: &UpdateAgentAvailabilitySnapshotParams<'_>, + ) -> 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) } @@ -730,18 +857,25 @@ impl IAgentMetadataRepository for ClaudeNativeSkillMetadataRepo { #[derive(Debug, Clone, PartialEq, Eq)] struct RuntimeStateSaveCall { conversation_id: String, + current_mode_id: Option>, current_model_id: Option>, } #[derive(Default)] struct StubAcpSessionRepo { + create_calls: Mutex>, runtime_state_saves: Mutex>, session_id: Mutex>, } impl StubAcpSessionRepo { + fn create_calls(&self) -> Vec { + self.create_calls.lock().unwrap().clone() + } + fn with_session_id(session_id: impl Into) -> Self { Self { + create_calls: Mutex::new(Vec::new()), runtime_state_saves: Mutex::new(Vec::new()), session_id: Mutex::new(Some(session_id.into())), } @@ -754,7 +888,6 @@ impl StubAcpSessionRepo { fn row_for(&self, conversation_id: &str) -> AcpSessionRow { AcpSessionRow { conversation_id: conversation_id.to_owned(), - agent_backend: "codex".into(), agent_source: "builtin".into(), agent_id: "codex".into(), session_id: self.session_id.lock().unwrap().clone(), @@ -766,17 +899,28 @@ impl StubAcpSessionRepo { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct CreateAcpSessionCall { + conversation_id: 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(Some(self.row_for(conversation_id))) } async fn create(&self, params: &CreateAcpSessionParams<'_>) -> Result { + self.create_calls.lock().unwrap().push(CreateAcpSessionCall { + conversation_id: params.conversation_id.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: 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: self.session_id.lock().unwrap().clone(), @@ -806,6 +950,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) @@ -950,6 +1095,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!({ @@ -989,28 +1171,28 @@ fn unique_test_workspace_path(label: &str) -> PathBuf { async fn upsert_test_assistant_definition( repo: &SqliteAssistantDefinitionRepository, definition_id: &str, - assistant_key: &str, - agent_backend: &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: "{}", avatar_type: "emoji", avatar_value: Some("🤖"), - agent_backend, + 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: "{}", @@ -1032,13 +1214,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, @@ -1050,7 +1231,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", @@ -1130,7 +1315,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() @@ -1247,6 +1432,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 { + assistant_definition_id: "asstdef_aionrs_missing_type", + enabled: true, + sort_order: 0, + agent_id_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 { + assistant_definition_id: "asstdef_acp_missing_type", + enabled: true, + sort_order: 0, + agent_id_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] @@ -2884,17 +3166,17 @@ 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_backend_override: None, + agent_id_override: None, last_used_at: None, }) .await .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: "[]", @@ -2904,7 +3186,7 @@ async fn set_config_option_persists_runtime_model_into_assistant_preference_when .await .unwrap(); - let conv = create_assistant_backed_conversation(&svc, "user_1", "acp", "codex", "assistant-acp-auto").await; + let conv = create_assistant_backed_conversation(&svc, "user_1", Some("acp"), "codex", "assistant-acp-auto").await; let agent = Arc::new(MockAgent::new(&conv.id)); task_mgr.insert_agent(&conv.id, AgentInstance::Mock(agent)); @@ -2974,17 +3256,17 @@ 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_backend_override: None, + agent_id_override: None, last_used_at: None, }) .await .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: "[]", @@ -2994,7 +3276,7 @@ async fn set_config_option_skips_preference_write_back_when_default_mode_is_fixe .await .unwrap(); - let conv = create_assistant_backed_conversation(&svc, "user_1", "acp", "codex", "assistant-acp-fixed").await; + let conv = create_assistant_backed_conversation(&svc, "user_1", Some("acp"), "codex", "assistant-acp-fixed").await; let agent = Arc::new(MockAgent::new(&conv.id)); task_mgr.insert_agent(&conv.id, AgentInstance::Mock(agent)); @@ -3044,17 +3326,17 @@ 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_backend_override: None, + agent_id_override: None, last_used_at: None, }) .await .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: "[]", @@ -3064,7 +3346,7 @@ async fn set_config_option_command_ack_does_not_persist_assistant_preference() { .await .unwrap(); - let conv = create_assistant_backed_conversation(&svc, "user_1", "acp", "codex", "assistant-acp-ack").await; + let conv = create_assistant_backed_conversation(&svc, "user_1", Some("acp"), "codex", "assistant-acp-ack").await; let agent = Arc::new( MockAgent::new(&conv.id).with_set_config_option_response(SetConfigOptionResponse { confirmation: ConfigOptionConfirmation::CommandAck, @@ -3107,17 +3389,17 @@ 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_backend_override: None, + agent_id_override: None, last_used_at: None, }) .await .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: "[]", @@ -3128,7 +3410,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", @@ -3168,17 +3450,17 @@ 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_backend_override: None, + agent_id_override: None, last_used_at: None, }) .await .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: "[]", @@ -3189,7 +3471,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", @@ -4026,6 +4308,87 @@ 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(), + }] + ); +} + +#[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()); +} + #[tokio::test] async fn send_message_recovers_when_finished_task_has_no_runtime_terminal() { let (svc, _broadcaster, repo, _default_task_mgr) = make_service(); @@ -5059,8 +5422,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"), @@ -5072,7 +5435,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, @@ -5093,17 +5456,17 @@ 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_backend_override: Some("codex"), + agent_id_override: Some("codex"), last_used_at: None, }) .await .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"]"#, @@ -5133,17 +5496,32 @@ async fn create_resolves_assistant_snapshot_and_updates_preferences() { .unwrap(); 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_eq!( + resp.assistant, + Some(aionui_api_types::ConversationAssistantIdentityResponse { + id: "preset-1".into(), + source: "builtin".into(), + name: "Preset".into(), + avatar: "🤖".into(), + backend: "codex".into(), + }) + ); + assert!(resp.extra.get("assistant_id").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()); + 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()); 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.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"); assert_eq!(snapshot.resolved_model_id.as_deref(), Some("new-model")); @@ -5154,6 +5532,203 @@ 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] +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 { + id: "asstdef_preset_legacy_seed", + assistant_id: "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_id: "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 { + assistant_definition_id: "asstdef_preset_legacy_seed", + enabled: true, + sort_order: 0, + agent_id_override: Some("codex"), + last_used_at: None, + }) + .await + .unwrap(); + preference_repo + .upsert(&UpsertAssistantPreferenceParams { + 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"]"#, + 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_id, "8e1acf31"); + 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![] }); + 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 { + assistant_definition_id: "asstdef_snapshot_identity", + enabled: true, + sort_order: 0, + agent_id_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_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_id, "8e1acf31"); + assert_eq!(create_calls[0].agent_source, "builtin"); + assert_eq!(create_calls[0].agent_id, "8e1acf31"); } #[tokio::test] @@ -5170,8 +5745,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"), @@ -5183,7 +5758,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, @@ -5204,17 +5779,17 @@ 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_backend_override: Some("codex"), + agent_id_override: Some("codex"), last_used_at: None, }) .await .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"]"#, @@ -5268,8 +5843,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"), @@ -5281,7 +5856,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, @@ -5302,17 +5877,17 @@ 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_backend_override: Some("codex"), + agent_id_override: Some("codex"), last_used_at: None, }) .await .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-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), }] ); diff --git a/crates/aionui-conversation/src/session_context.rs b/crates/aionui-conversation/src/session_context.rs index 0139ec82a..3e6760886 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,31 @@ 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()) { + 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, + "session_context: restored ACP identity from persisted acp_session row" + ); + config.agent_id = Some(session_row.agent_id.clone()); + if let Some(metadata) = metadata { + config.backend = metadata.backend; + } + return Ok(()); + } + let backend = config.backend.as_deref().filter(|value| !value.is_empty()); let agent_source = extra .get("agent_source") @@ -622,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", }) @@ -660,6 +677,28 @@ 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_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; diff --git a/crates/aionui-conversation/src/turn_orchestrator.rs b/crates/aionui-conversation/src/turn_orchestrator.rs index b9169c150..0691205fa 100644 --- a/crates/aionui-conversation/src/turn_orchestrator.rs +++ b/crates/aionui-conversation/src/turn_orchestrator.rs @@ -83,6 +83,7 @@ impl ConversationTurnOrchestrator { async fn run_attempt(&self, input: TurnAttemptInput) -> Result { let build_started_at = now_ms(); + let availability_agent_id = availability_agent_id(&input.build_options); let backend = acp_backend_from_build_options(&input.build_options).map(str::to_owned); info!( conversation_id = %input.conv_id, @@ -122,6 +123,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( &input.conv_id, @@ -205,10 +214,20 @@ impl ConversationTurnOrchestrator { let send_agent = agent.clone(); let conv_id_send = input.conv_id.clone(); let turn_id_for_send = input.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!( @@ -413,6 +432,10 @@ impl ConversationTurnOrchestrator { } }; + if !final_failed { + record_agent_session_success(&self.service, availability_agent_id(&input.build_options).as_deref()).await; + } + let was_deleting = turn_claim.release_for_turn(&turn_id); self.service .complete_released_turn(&conv_id, &turn_id, was_deleting) @@ -427,3 +450,61 @@ 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" + ); + } +} + +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" + ); + } +} diff --git a/crates/aionui-cron/src/executor.rs b/crates/aionui-cron/src/executor.rs index d2a32f6c0..829d79249 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, }; @@ -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 } @@ -430,12 +440,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: None, + assistant, source: None, channel_chat_id: None, extra, @@ -973,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; } @@ -1023,13 +1011,6 @@ async fn inject_agent_identity( if let Some(backend) = meta.backend { extra.insert("backend".to_owned(), serde_json::Value::String(backend)); } - 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())); } } @@ -1054,7 +1035,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())); } - if let Some(custom_agent_id) = &config.custom_agent_id { + 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 !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()), @@ -1089,6 +1081,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, @@ -1100,6 +1114,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())); @@ -1115,27 +1130,17 @@ 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(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())); } @@ -1167,16 +1172,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() }; @@ -1252,14 +1252,14 @@ 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, + 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, config_options: None, workspace: Some(ensure_named_workspace_path("aionui-cron-sample-job-workspace")), }), @@ -1538,14 +1538,18 @@ 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, + assistant_id: None, custom_agent_id: None, - 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, }), @@ -1557,26 +1561,30 @@ 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, + assistant_id: None, custom_agent_id: None, - 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] @@ -1592,18 +1600,18 @@ 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, + assistant_id: None, custom_agent_id: None, - preset_agent_type: None, mode: None, model_id: Some("gpt-5".into()), + model: None, config_options: None, workspace: None, }), @@ -1627,12 +1635,28 @@ 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"); 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; @@ -1693,6 +1717,40 @@ mod tests { assert_eq!(extra["preset_enabled_skills"], serde_json::json!(["cron-cron_test1"])); } + #[tokio::test] + 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); + config.custom_agent_id = None; + + let extra = build_conversation_extra(®istry, &job, None).await; + + 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] + 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; @@ -2988,6 +3046,21 @@ 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 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-cron/src/service.rs b/crates/aionui-cron/src/service.rs index bdead100c..7a8e9445f 100644 --- a/crates/aionui-cron/src/service.rs +++ b/crates/aionui-cron/src/service.rs @@ -7,9 +7,13 @@ 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, + UpdateCronJobParams, resolve_agent_binding_from_rows, }; -use aionui_db::{ICronRepository, UpdateCronJobParams}; use tracing::{error, info, warn}; use crate::events::CronEventEmitter; @@ -35,31 +39,40 @@ 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, + agent_metadata_repo: Arc, + assistant_definition_repo: Arc, + assistant_overlay_repo: Arc, scheduler: Arc, executor: Arc, emitter: CronEventEmitter, 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, - scheduler: Arc, - executor: Arc, - emitter: CronEventEmitter, - data_dir: PathBuf, - ) -> Self { + pub fn new(deps: CronServiceDeps) -> Self { Self { - 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, } } @@ -68,27 +81,38 @@ impl CronService { // ----------------------------------------------------------------------- pub async fn add_job(&self, req: CreateCronJobRequest) -> Result { + 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)?; - 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 = match runtime_agent_type { + Some(agent_type) => agent_type, + None => self.resolve_new_job_agent_type(req.agent_config.as_ref()).await?, + }; + 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)?; 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, - 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 = match req.agent_config { + Some(config) => Some( + self.build_cron_agent_config( + &resolved_agent_type, + sanitize_agent_config_dto(config), + assistant_backend_override.as_deref(), + ) + .await?, + ), + None => None, + }; let now = now_ms(); let next_run_at = compute_next_run(&schedule, now); @@ -103,7 +127,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, @@ -137,7 +161,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(); @@ -160,19 +184,10 @@ 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))?; - job.agent_config = Some(CronAgentConfig { - backend: config_dto.backend.clone(), - name: config_dto.name.clone(), - cli_path: config_dto.cli_path.clone(), - is_preset: config_dto.is_preset, - custom_agent_id: config_dto.custom_agent_id.clone(), - preset_agent_type: config_dto.preset_agent_type.clone(), - mode: config_dto.mode.clone(), - model_id: config_dto.model_id.clone(), - config_options: config_dto.config_options.clone(), - workspace: config_dto.workspace.clone(), - }); + 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?); } if let Some(title) = &req.conversation_title { job.conversation_title = Some(title.clone()); @@ -216,7 +231,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> { @@ -226,7 +243,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) } // ----------------------------------------------------------------------- @@ -290,13 +313,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"); @@ -354,7 +384,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(); @@ -433,6 +464,54 @@ impl CronService { cron_job_to_response(job) } + async fn resolve_new_job_agent_type( + &self, + agent_config: Option<&aionui_api_types::CronAgentConfigWriteDto>, + ) -> Result { + let Some(assistant_id) = agent_config.and_then(|config| config.assistant_id.as_deref()) else { + return Err(CronError::InvalidAgentConfig( + "assistant_id is required for new cron jobs".into(), + )); + }; + + 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_assistant_id(assistant_id) + .await? + .ok_or_else(|| CronError::InvalidAgentConfig(format!("assistant '{assistant_id}' not found")))?; + 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()) + .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()) + } + async fn bind_existing_conversation_if_needed(&self, job: &CronJob) { if !matches!(job.execution_mode, ExecutionMode::Existing) || job.conversation_id.trim().is_empty() { return; @@ -828,6 +907,252 @@ impl CronService { ); } } + + async fn build_agent_config_from_conversation( + &self, + row: &aionui_db::models::ConversationRow, + ) -> ( + 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.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_id.trim().to_owned()) + .filter(|value| !value.is_empty()); + 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()), + 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 = 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 + .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 { + 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") + .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())) + }) + }) + .or_else(|| { + assistant_snapshot + .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"]), + }; + + (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 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 { + name: config.name, + cli_path: config.cli_path, + is_preset: None, + assistant_id: config.assistant_id, + custom_agent_id: 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, + }) + } + + 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_assistant_id(assistant_id).await? else { + return Ok(None); + }; + 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()) + .unwrap_or(definition.agent_id.as_str()); + + 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) + .map(|binding| binding.runtime_backend) + .unwrap_or_else(|| agent_id.to_owned())) + } } // --------------------------------------------------------------------------- @@ -859,21 +1184,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) = build_agent_config_from_conversation(&row); - (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) } }; @@ -885,13 +1211,15 @@ impl aionui_conversation::response_middleware::ICronService for CronService { message: Some(params.message.clone()), conversation_id: conversation_id.to_owned(), conversation_title, - 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), assistant_backend_override) + .await + { Ok(job) => { if let Err(err) = self .executor @@ -1027,78 +1355,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 { - get_string(&extra, &["backend"]) - .or_else(|| { - model - .map(|value| value.provider_id.clone()) - .filter(|value| !value.is_empty()) - }) - .unwrap_or_else(|| row.r#type.clone()) - }; - - let preset_assistant_id = get_string(&extra, &["preset_assistant_id", "presetAssistantId"]); - 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 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::CronAgentConfigDto { - 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) - }), - is_preset, - 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| { - 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 @@ -1113,33 +1369,58 @@ 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::CronAgentConfigDto>, + agent_config: Option<&aionui_api_types::CronAgentConfigWriteDto>, ) -> Result<(), CronError> { if agent_type != "aionrs" { return Ok(()); } - let backend_ok = agent_config.is_some_and(|c| !c.backend.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), @@ -1205,20 +1486,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 config = CronAgentConfig { - backend: c.backend.clone(), - name: c.name.clone(), - cli_path: c.cli_path.clone(), - is_preset: c.is_preset, - 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(), - }; - 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 { @@ -1233,7 +1504,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() { @@ -1249,6 +1519,20 @@ 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.assistant_id.as_mut() { + let trimmed = value.trim().to_owned(); + if trimmed.is_empty() { + config.assistant_id = None; + } else { + *value = trimmed; + } + } + config +} + fn schedule_from_dto_with_existing_timezone(dto: &CronScheduleDto, existing: &CronSchedule) -> CronSchedule { match dto { CronScheduleDto::Cron { expr, tz, description } => CronSchedule::Cron { @@ -1327,16 +1611,18 @@ mod tests { // -- validate_aionrs_agent_config ---------------------------------------- - fn agent_cfg_dto(backend: &str) -> aionui_api_types::CronAgentConfigDto { - aionui_api_types::CronAgentConfigDto { - backend: backend.to_owned(), + fn agent_cfg_dto(provider_id: &str) -> aionui_api_types::CronAgentConfigWriteDto { + aionui_api_types::CronAgentConfigWriteDto { name: "provider".into(), cli_path: None, - is_preset: None, - custom_agent_id: None, - preset_agent_type: 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, } @@ -1355,14 +1641,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(_))); @@ -1370,12 +1656,43 @@ 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()); } + #[test] + fn sanitize_agent_config_dto_clears_legacy_ids_when_assistant_id_present() { + let config = aionui_api_types::CronAgentConfigWriteDto { + 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, + }; + + let sanitized = sanitize_agent_config_dto(config); + + assert_eq!(sanitized.assistant_id.as_deref(), Some("assistant-1")); + } + + #[test] + fn sanitize_agent_config_dto_rejects_legacy_custom_agent_id_without_assistant_id() { + let err = serde_json::from_value::(serde_json::json!({ + "name": "Helper", + "custom_agent_id": "legacy-assistant", + "mode": "default", + "model_id": "claude-sonnet-4", + })) + .expect_err("legacy custom_agent_id must be rejected"); + + assert!(err.to_string().contains("custom_agent_id")); + } + // -- parse_execution_mode ------------------------------------------------- #[test] @@ -1487,6 +1804,64 @@ mod tests { assert!(params.next_run_at.is_some()); } + #[test] + fn build_update_params_strips_legacy_ids_when_assistant_id_present() { + let mut job = sample_job(); + job.agent_config = Some(CronAgentConfig { + name: "Helper".into(), + cli_path: None, + is_preset: None, + assistant_id: Some("assistant-1".into()), + custom_agent_id: None, + mode: Some("default".into()), + model_id: Some("claude-sonnet-4".into()), + model: None, + config_options: None, + workspace: None, + }); + let req = UpdateCronJobRequest { + name: None, + description: None, + enabled: None, + schedule: None, + message: None, + execution_mode: None, + agent_config: Some(aionui_api_types::CronAgentConfigWriteDto { + 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, + }), + 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.is_preset.is_none()); + } + + #[test] + fn build_update_params_rejects_legacy_custom_agent_id_without_assistant_id() { + let err = serde_json::from_value::(serde_json::json!({ + "name": "Helper", + "custom_agent_id": "legacy-assistant", + "mode": "default", + "model_id": "claude-sonnet-4", + })) + .expect_err("legacy custom_agent_id must be rejected"); + + assert!(err.to_string().contains("custom_agent_id")); + } + #[test] fn preserves_existing_cron_timezone_when_update_omits_tz() { let existing = CronSchedule::Cron { diff --git a/crates/aionui-cron/src/types.rs b/crates/aionui-cron/src/types.rs index 8b4611ebb..36f00c100 100644 --- a/crates/aionui-cron/src/types.rs +++ b/crates/aionui-cron/src/types.rs @@ -2,10 +2,10 @@ 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; +use aionui_common::{ProviderWithModel, TimestampMs}; use aionui_db::models::CronJobRow; use serde::{Deserialize, Serialize}; @@ -125,21 +125,22 @@ 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub is_preset: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom_agent_id: Option, + pub assistant_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub preset_agent_type: Option, + pub custom_agent_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 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, @@ -208,7 +209,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, @@ -280,7 +281,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(), @@ -329,17 +329,25 @@ 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, - 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 canonical_assistant_id = c.assistant_id.clone().or_else(|| c.custom_agent_id.clone()); + let assistant_backed = canonical_assistant_id.is_some(); + CronAgentConfigReadDto { + 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: canonical_assistant_id, + custom_agent_id: if assistant_backed { + None + } else { + c.custom_agent_id.clone() + }, + mode: c.mode.clone(), + model_id: c.model_id.clone(), + model: c.model.clone(), + config_options: c.config_options.clone(), + workspace: c.workspace.clone(), + } }); CronJobResponse { @@ -555,7 +563,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, @@ -583,14 +590,14 @@ 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, + assistant_id: None, custom_agent_id: None, - preset_agent_type: None, mode: None, model_id: None, + model: None, config_options: None, workspace: None, }), @@ -630,7 +637,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] @@ -811,6 +818,43 @@ 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 { + 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()), + 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()), + }), + ..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_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 { @@ -821,6 +865,65 @@ 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 { + 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()), + mode: Some("default".into()), + model_id: Some("claude-sonnet-4".into()), + model: None, + 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()); + } + + #[test] + fn domain_to_dto_keeps_model_for_aionrs_assistant_jobs() { + let job = CronJob { + agent_type: "aionrs".into(), + agent_config: Some(CronAgentConfig { + name: "Gemini Bare Assistant".into(), + cli_path: None, + is_preset: None, + assistant_id: Some("bare-gemini".into()), + custom_agent_id: 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()), + }), + ..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.model.as_ref().map(|model| model.provider_id.as_str()), + Some("gemini") + ); + } + // -- DTO → Domain schedule ------------------------------------------------ #[test] diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index 29de45d3c..c2431a2c8 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -17,21 +17,23 @@ 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::{ ConversationFilters, ConversationRowUpdate, IAcpSessionRepository, IAgentMetadataRepository, - IConversationRepository, ICronRepository, MessagePageParams, MessagePageResult, MessageRowUpdate, MessageSearchRow, - SqliteAcpSessionRepository, SqliteAgentMetadataRepository, SqliteCronRepository, init_database_memory, - models::MessageRow, + IAssistantDefinitionRepository, IAssistantOverlayRepository, IConversationRepository, ICronRepository, + MessagePageParams, MessagePageResult, MessageRowUpdate, MessageSearchRow, SqliteAcpSessionRepository, + SqliteAgentMetadataRepository, SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, + SqliteCronRepository, UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, init_database_memory, + models::{ConversationAssistantSnapshotRow, MessageRow}, }; 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 ──────────────────────────────────────────── @@ -102,6 +104,7 @@ struct StubConvRepo { messages: Mutex>, artifacts: Mutex>, rows: Mutex>, + assistant_snapshots: Mutex>, } impl StubConvRepo { @@ -110,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()), } } @@ -130,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] @@ -294,6 +305,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(), @@ -324,6 +365,72 @@ 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 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(), @@ -345,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(()) @@ -554,6 +669,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())); @@ -595,7 +714,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, @@ -610,12 +729,133 @@ 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(CronServiceDeps { + repo: cron_repo.clone(), + agent_metadata_repo, + 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, + "asstdef_default", + "assistant-default", + "claude", + ) + .await; + seed_bare_assistant_definitions(&assistant_definition_repo).await; 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.clone()); + 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(CronServiceDeps { + repo: cron_repo.clone(), + agent_metadata_repo, + 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, + "asstdef_default", + "assistant-default", + "claude", + ) + .await; + seed_bare_assistant_definitions(&assistant_definition_repo).await; + + 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(), @@ -625,10 +865,97 @@ 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(), created_by: "user".into(), execution_mode: None, - agent_config: None, + agent_config: Some(aionui_api_types::CronAgentConfigWriteDto { + 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, + }), + } +} + +async fn seed_assistant_definition( + repo: &Arc, + definition_id: &str, + assistant_id: &str, + agent_backend: &str, +) { + let agent_id = seeded_agent_id(agent_backend); + repo.upsert(&UpsertAssistantDefinitionParams { + id: definition_id, + assistant_id, + source: "user", + owner_type: "user", + source_ref: Some(assistant_id), + source_version: None, + source_hash: None, + name: assistant_id, + name_i18n: "{}", + description: Some("test assistant"), + description_i18n: "{}", + avatar_type: "emoji", + avatar_value: Some("🤖"), + agent_id, + 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_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, + agent_backend_override: Option<&str>, +) { + let agent_id_override = agent_backend_override.map(seeded_agent_id); + repo.upsert(&UpsertAssistantOverlayParams { + assistant_definition_id: definition_id, + enabled: true, + sort_order: 0, + 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, } } @@ -675,21 +1002,167 @@ async fn cj1_create_cron_job() { } #[tokio::test] -async fn create_job_rejects_deprecated_agent_types() { +async fn create_job_strips_legacy_agent_ids_when_assistant_id_present() { + 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::CronAgentConfigWriteDto { + 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, + }); + + 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.is_preset.is_none()); +} + +#[tokio::test] +async fn create_job_derives_assistant_runtime_without_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 = 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 { + 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(); + + assert_eq!(job.agent_type, "acp"); +} + +#[tokio::test] +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_config = None; + + let err = svc.add_job(req).await.unwrap_err(); + assert!(matches!(err, aionui_cron::error::CronError::InvalidAgentConfig(_))); + assert!(err.to_string().contains("assistant_id is required for new cron jobs")); +} + +#[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_config = Some(aionui_api_types::CronAgentConfigWriteDto { + 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, + }); + + 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_config = Some(aionui_api_types::CronAgentConfigWriteDto { + 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, + }); + + let job = svc.add_job(req).await.unwrap(); + + 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_config = Some(aionui_api_types::CronAgentConfigWriteDto { + 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, + }); + + 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")); +} + +#[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 { + 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, + }); + + 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 ────────────────────────────── @@ -827,6 +1300,85 @@ 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, _, _, _, 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 + .unwrap(); + + let req = UpdateCronJobRequest { + name: None, + description: None, + enabled: None, + schedule: None, + message: None, + execution_mode: None, + agent_config: Some(aionui_api_types::CronAgentConfigWriteDto { + 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, + }), + 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.is_preset.is_none()); +} + +#[tokio::test] +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())) + .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 { + 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, + }), + 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' not found"), + "unexpected error: {err}" + ); +} + // ── CJ-9: Update schedule type ──────────────────────────────────── #[tokio::test] @@ -1284,7 +1836,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; @@ -1311,7 +1863,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")); @@ -1321,6 +1872,157 @@ async fn icron_service_create_job_inherits_conversation_mode_and_backend() { ); } +#[tokio::test] +async fn icron_service_create_job_ignores_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.model_id.as_deref(), Some("gemini-2.5-pro")); +} + +#[tokio::test] +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, + "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!(job.agent_type, "aionrs"); + assert_eq!(config.assistant_id.as_deref(), Some("assistant-override")); + 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_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_id: "assistant-snapshot".into(), + assistant_source: "bare".into(), + assistant_name: "Snapshot Assistant".into(), + assistant_avatar_type: "emoji".into(), + assistant_avatar_value: Some("S".into()), + agent_id: "8e1acf31".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 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.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; 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..e485696ca --- /dev/null +++ b/crates/aionui-db/migrations/013_agent_connection_snapshot.sql @@ -0,0 +1,644 @@ +-- 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; + +-- 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; + +-- Assistant/agent unification: assistant storage now binds to the concrete +-- agent catalog row. Runtime backend labels remain compatibility/runtime +-- fields on legacy mirrors and conversation extra. +-- +-- 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( + ( + 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 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); + +-- 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; + +-- 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 +-- 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 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 (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 + ), + 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 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 (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 + ), + 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/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 f9cb9bcfc..423b676ba 100644 --- a/crates/aionui-db/src/lib.rs +++ b/crates/aionui-db/src/lib.rs @@ -1,12 +1,17 @@ #![warn(clippy::disallowed_types)] //! SQLite database layer: init, migrations, repository traits, and implementations. +mod agent_binding; mod database; mod error; mod legacy_handoff; 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, }; @@ -14,9 +19,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/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/agent_metadata.rs b/crates/aionui-db/src/models/agent_metadata.rs index 8e3bac821..ec1b19653 100644 --- a/crates/aionui-db/src/models/agent_metadata.rs +++ b/crates/aionui-db/src/models/agent_metadata.rs @@ -48,6 +48,19 @@ 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 command_override: Option, + pub env_override: Option, + pub created_at: TimestampMs, pub updated_at: TimestampMs, } @@ -97,3 +110,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/assistant.rs b/crates/aionui-db/src/models/assistant.rs index eeb92cf3d..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,24 +28,20 @@ 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, } /// 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, @@ -58,7 +53,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, @@ -82,10 +77,10 @@ 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_backend_override: Option, + pub agent_id_override: Option, pub last_used_at: Option, pub created_at: TimestampMs, pub updated_at: TimestampMs, @@ -94,7 +89,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, @@ -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,23 +139,19 @@ 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`. #[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>, @@ -174,7 +163,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>, @@ -195,17 +184,17 @@ 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_backend_override: Option<&'a str>, + pub agent_id_override: Option<&'a str>, pub last_used_at: Option, } /// 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 b73c35c19..514496ca6 100644 --- a/crates/aionui-db/src/models/conversation.rs +++ b/crates/aionui-db/src/models/conversation.rs @@ -39,12 +39,12 @@ 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, 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, @@ -64,12 +64,12 @@ 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, 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/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/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/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/agent_metadata.rs b/crates/aionui-db/src/repository/agent_metadata.rs index d99c57b89..33df906ad 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,25 @@ 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>; + + /// 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/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_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_agent_metadata.rs b/crates/aionui-db/src/repository/sqlite_agent_metadata.rs index 0bd8e57a1..1bdcdaeec 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,67 @@ 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 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 = ?") @@ -431,6 +494,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; @@ -458,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"); + } } diff --git a/crates/aionui-db/src/repository/sqlite_assistant.rs b/crates/aionui-db/src/repository/sqlite_assistant.rs index 17c9dcad4..6555509be 100644 --- a/crates/aionui-db/src/repository/sqlite_assistant.rs +++ b/crates/aionui-db/src/repository/sqlite_assistant.rs @@ -52,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) @@ -86,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), @@ -109,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 = ? \ @@ -118,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) @@ -148,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, \ @@ -171,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) @@ -200,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)), @@ -296,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?; @@ -374,21 +352,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) @@ -414,9 +392,9 @@ 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_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, @@ -424,8 +402,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, @@ -437,7 +415,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, @@ -456,8 +434,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) @@ -469,7 +447,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) @@ -490,23 +468,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) @@ -515,11 +493,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) } @@ -536,36 +516,36 @@ 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 + 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_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.assistant_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) .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) @@ -574,12 +554,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) } @@ -587,10 +568,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, @@ -598,7 +579,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) @@ -609,17 +590,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) @@ -642,19 +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(), }; - 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, @@ -665,11 +644,10 @@ 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) - .bind(&definition.agent_backend) .bind(&default_skills) .bind(&custom_skill_names) .bind(&disabled_builtin) @@ -684,30 +662,27 @@ 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?; } 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 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_key) + .bind(&definition.assistant_id) .bind(enabled) .bind(sort_order) - .bind(agent_backend_override) .bind(last_used_at) .bind(state.map(|row| row.updated_at).unwrap_or(definition.updated_at)) .execute(pool) @@ -762,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, @@ -776,8 +750,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), @@ -789,7 +763,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"), @@ -820,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); @@ -867,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); @@ -923,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); @@ -1072,12 +1044,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"); @@ -1089,10 +1061,10 @@ 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_backend_override: Some("claude"), + agent_id_override: Some("claude"), last_used_at: Some(1234), }) .await @@ -1100,10 +1072,10 @@ 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_backend_override.as_deref(), Some("claude")); + assert_eq!(list[0].agent_id_override.as_deref(), Some("claude")); } #[tokio::test] @@ -1112,7 +1084,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"]"#, @@ -1123,7 +1095,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"]"#); } @@ -1133,10 +1105,10 @@ 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_backend_override: Some("claude"), + agent_id_override: Some("claude"), last_used_at: Some(999), }) .await @@ -1150,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"]"#)); @@ -1164,13 +1135,49 @@ mod tests { assert_eq!(legacy_override.last_used_at, Some(999)); } + #[tokio::test] + 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"; + params.agent_id = "cc126dd5"; + let definition = d.upsert(¶ms).await.unwrap(); + let state = s + .upsert(&UpsertAssistantOverlayParams { + assistant_definition_id: &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.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!(legacy_override.enabled); + } + #[tokio::test] async fn rebuild_legacy_mirror_skips_builtin_assistant_rows() { 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"), @@ -1182,7 +1189,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, @@ -1203,10 +1210,10 @@ mod tests { .unwrap(); let state = s .upsert(&UpsertAssistantOverlayParams { - definition_id: &definition.definition_id, + assistant_definition_id: &definition.id, enabled: false, sort_order: 3, - agent_backend_override: Some("claude"), + agent_id_override: Some("claude"), last_used_at: Some(42), }) .await @@ -1231,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/src/repository/sqlite_conversation.rs b/crates/aionui-db/src/repository/sqlite_conversation.rs index 86705e653..b7951a195 100644 --- a/crates/aionui-db/src/repository/sqlite_conversation.rs +++ b/crates/aionui-db/src/repository/sqlite_conversation.rs @@ -424,12 +424,12 @@ 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, assistant_avatar_value, - agent_backend, + agent_id, rules_content, default_model_mode, resolved_model_id, @@ -445,12 +445,12 @@ 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, 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, @@ -465,12 +465,12 @@ 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) .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/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/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..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,13 +75,41 @@ 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")); 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(); @@ -88,8 +117,8 @@ 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, - name, name_i18n, description_i18n, avatar_type, agent_backend, + 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, custom_skill_names, default_disabled_builtin_skill_ids, default_mcps_mode, default_mcp_ids, @@ -112,8 +141,8 @@ 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, - name, name_i18n, description_i18n, avatar_type, agent_backend, + 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, custom_skill_names, default_disabled_builtin_skill_ids, default_mcps_mode, default_mcp_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..e9de41af5 --- /dev/null +++ b/crates/aionui-db/tests/cron_assistant_first_migration.rs @@ -0,0 +1,426 @@ +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(); + + 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( + 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 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 + 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); +} + +#[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"); +} 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-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"]); diff --git a/crates/aionui-team-prompts/src/guide.rs b/crates/aionui-team-prompts/src/guide.rs index b19220a57..5b7f64b84 100644 --- a/crates/aionui-team-prompts/src/guide.rs +++ b/crates/aionui-team-prompts/src/guide.rs @@ -36,21 +36,21 @@ 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. +### 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 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) | -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\", \"confirm\") 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.) -6. After `aion_create_team` returns -> end this solo turn and hand off to the created Team conversation. Do NOT call `team_*` tools from this solo Guide MCP session. -7. User declines or wants changes -> adjust or proceed solo. Do not mention Team again unless the user asks. + | 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. 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 -Before team creation: use **only** `aion_create_team` and `aion_list_models`. After `aion_create_team` succeeds: do not call any `team_*` tools in this solo turn. Team tools are only for normal Team runtime after the Team page accepts the user's first Team message and an active `TeamRun` exists."; +Before team creation: use **only** `aion_create_team` and `aion_list_models`. After `aion_create_team` succeeds: use team tools (`team_spawn_agent`, `team_send_message`, `team_members`, `team_task_create`, etc.) to manage your team."; pub fn is_solo_team_guide_backend(backend: &str) -> bool { SOLO_TEAM_GUIDE_BACKENDS.contains(&backend) @@ -61,11 +61,11 @@ pub fn build_solo_team_guide_prompt(backend: &str) -> String { } pub fn build_solo_team_guide_prompt_with_label(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 @@ -74,7 +74,6 @@ pub fn build_solo_team_guide_prompt_with_label(backend: &str, leader_label: Opti .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)] @@ -82,27 +81,36 @@ mod tests { use super::*; #[test] - fn guide_prompt_hands_off_after_create_team() { + fn guide_prompt_uses_team_tools_after_create_team() { let prompt = build_solo_team_guide_prompt("claude"); assert!(prompt.contains("aion_create_team")); assert!(prompt.contains("aion_list_models")); - assert!(prompt.contains("hand off to the created Team conversation")); - assert!(!prompt.contains("Immediately")); - assert!(!prompt.contains( + assert!(prompt.contains("only use returned assistant_id values with `team_spawn_agent`")); + assert!(prompt.contains( "use team tools (`team_spawn_agent`, `team_send_message`, `team_members`, `team_task_create`, etc.) to manage your team" )); + assert!(!prompt.contains("Immediately")); + assert!(!prompt.contains("hand off to the created Team conversation")); } #[test] fn guide_prompt_supports_preset_leader_label() { let prompt = build_solo_team_guide_prompt_with_label("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("| Developer | Implement features | Suitable assistant | (model from list) |")); + assert!(prompt.contains("| Tester | Write and run tests | Suitable assistant | (model from list) |")); } #[test] fn 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] + fn whitespace_label_treated_as_absent() { + let prompt = build_solo_team_guide_prompt_with_label("codex", Some(" ")); + assert!(prompt.contains("| Leader | Coordinate and review | Current assistant (codex) | (default) |")); + assert!(!prompt.contains("()")); } } diff --git a/crates/aionui-team-prompts/src/prompt_templates/lead.txt b/crates/aionui-team-prompts/src/prompt_templates/lead.txt index d93d4dd02..60db3ee89 100644 --- a/crates/aionui-team-prompts/src/prompt_templates/lead.txt +++ b/crates/aionui-team-prompts/src/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, or confirmation workflow until there is a concrete task that may actually need more teammates ## Your Teammates ${teammateList}${availableTypesSection}${availableAssistantsSection} @@ -24,23 +24,24 @@ 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 -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 -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 agent type +- 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 @@ -83,11 +84,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 +98,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-prompts/src/role_prompt.rs b/crates/aionui-team-prompts/src/role_prompt.rs index a26dcc2b6..8404f9a51 100644 --- a/crates/aionui-team-prompts/src/role_prompt.rs +++ b/crates/aionui-team-prompts/src/role_prompt.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::fmt::Write; use crate::governance::with_team_governance; +use serde::Serialize; pub const LEAD_PROMPT_TEMPLATE: &str = include_str!("prompt_templates/lead.txt"); @@ -43,12 +44,12 @@ pub struct AvailableAgentType { pub display_name: String, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct AvailableAssistant { - pub custom_agent_id: String, + pub assistant_id: String, pub name: String, pub backend: String, - pub description: Option, + pub description: String, pub skills: Vec, } @@ -124,41 +125,30 @@ fn render_teammate_list(teammates: &[TeamPromptAgent], renamed_agents: &HashMap< } 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"); - for (idx, agent_type) in agent_types.iter().enumerate() { - if idx > 0 { - out.push('\n'); - } - let _ = write!(out, "- `{}` — {}", agent_type.agent_type, agent_type.display_name); - } - out.push_str("\n\nUse `team_list_models` to query available models for each agent type 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, assistant) in assistants.iter().enumerate() { if idx > 0 { out.push('\n'); } - let desc = assistant - .description - .as_deref() - .filter(|description| !description.is_empty()) - .map(|description| format!(" — {description}")) - .unwrap_or_default(); + let desc = if assistant.description.is_empty() { + String::new() + } else { + format!(" — {}", assistant.description) + }; let skills = if assistant.skills.is_empty() { String::new() } else { @@ -166,22 +156,22 @@ fn render_available_assistants_section(assistants: &[AvailableAssistant]) -> Str }; let _ = write!( out, - "- `{}` ({}, backend: {}){}{}", - assistant.custom_agent_id, assistant.name, assistant.backend, desc, skills, + "- `{}` ({}){}{}", + assistant.assistant_id, assistant.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 Agent Types\" section.\n\n\ - Pass the preset's ID as `custom_agent_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 } @@ -320,22 +310,26 @@ mod tests { fn lead_prompt_prepends_governance_and_fills_sections() { let renamed = HashMap::new(); let teammate = prompt_agent("worker-1", "Worker", TeamPromptRole::Teammate); - let agent_types = vec![AvailableAgentType { - agent_type: "claude".to_owned(), - display_name: "Claude".to_owned(), + let assistants = vec![AvailableAssistant { + assistant_id: "word-creator".to_owned(), + name: "Word Creator".to_owned(), + backend: "claude".to_owned(), + description: "Drafts documents".to_owned(), + skills: vec!["docx".to_owned()], }]; let prompt = build_lead_prompt(&LeadPromptParams { team_name: "Alpha", teammates: &[teammate], - available_agent_types: &agent_types, - available_assistants: &[], + available_agent_types: &[], + available_assistants: &assistants, renamed_agents: &renamed, team_workspace: None, }); assert!(prompt.starts_with("## Team Governance")); assert!(prompt.contains("- Worker (claude, status: unknown)")); - assert!(prompt.contains("## Available Agent Types for Spawning")); + assert!(prompt.contains("## Available Assistants for Spawning")); + assert!(prompt.contains("Pass the assistant's ID as `assistant_id`")); assert!(!prompt.contains("${")); } diff --git a/crates/aionui-team-prompts/src/tools.rs b/crates/aionui-team-prompts/src/tools.rs index 4d3170002..dad81bffd 100644 --- a/crates/aionui-team-prompts/src/tools.rs +++ b/crates/aionui-team-prompts/src/tools.rs @@ -31,35 +31,41 @@ 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, 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 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."#; 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. + "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 assistant_id to query models for a specific assistant, or omit it to see all backends."; 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\". -After confirming a match, call team_spawn_agent with the same custom_agent_id."; +Only works on assistants listed in \"Available Assistants for Spawning\". +After confirming a match, call team_spawn_agent with the same assistant_id."; + +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."; pub fn team_tool_specs() -> Vec { vec![ @@ -84,13 +90,11 @@ pub fn team_tool_specs() -> 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." }, - "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." }, - "backend": { "type": "string", "description": "Legacy alias for agent_type. Prefer agent_type." }, + "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"] }), }, TeamToolSpec { @@ -169,14 +173,12 @@ pub fn team_tool_specs() -> Vec { }), }, TeamToolSpec { - name: "team_list_models", + name: "team_list_assistants", permission: TeamToolPermission::AnyTeamAgent, - description: TEAM_LIST_MODELS_DESCRIPTION, + description: TEAM_LIST_ASSISTANTS_DESCRIPTION, 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." } - } + "properties": {} }), }, TeamToolSpec { @@ -186,10 +188,21 @@ pub fn team_tool_specs() -> 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 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": ["custom_agent_id"] + "required": ["assistant_id"] + }), + }, + TeamToolSpec { + name: "team_list_models", + permission: TeamToolPermission::AnyTeamAgent, + description: TEAM_LIST_MODELS_DESCRIPTION, + input_schema: json!({ + "type": "object", + "properties": { + "assistant_id": { "type": "string", "description": "Assistant ID to query. When provided, returns models for the backend behind that assistant. Shows all backends when omitted." } + } }), }, ] @@ -256,9 +269,25 @@ mod tests { ("team_members", TeamToolPermission::AnyTeamAgent), ("team_rename_agent", TeamToolPermission::LeadOnly), ("team_shutdown_agent", TeamToolPermission::LeadOnly), - ("team_list_models", TeamToolPermission::AnyTeamAgent), + ("team_list_assistants", TeamToolPermission::AnyTeamAgent), ("team_describe_assistant", TeamToolPermission::AnyTeamAgent), + ("team_list_models", TeamToolPermission::AnyTeamAgent), ] ); } + + #[test] + fn spawn_schema_is_assistant_first() { + let descriptor = visible_team_tool_descriptors(true) + .into_iter() + .find(|tool| tool.name == "team_spawn_agent") + .expect("team_spawn_agent descriptor"); + let props = descriptor.input_schema["properties"].as_object().unwrap(); + let required = descriptor.input_schema["required"].as_array().unwrap(); + let required_names: Vec<_> = required.iter().filter_map(|value| value.as_str()).collect(); + assert!(props.contains_key("assistant_id")); + assert!(!props.contains_key("agent_type")); + assert!(!props.contains_key("backend")); + assert!(required_names.contains(&"assistant_id")); + } } 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/error.rs b/crates/aionui-team/src/error.rs index a96be7dc5..915f669c0 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,25 @@ 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/events.rs b/crates/aionui-team/src/events.rs index b63e686c0..e80dc26c9 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, @@ -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, @@ -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/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/guide/server.rs b/crates/aionui-team/src/guide/server.rs index 4770e05c2..de74fdd6e 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; @@ -132,15 +133,38 @@ 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 + 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"] })); } @@ -165,20 +189,30 @@ 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 // --------------------------------------------------------------------------- -fn build_create_team_handoff_next_step(summary: &str) -> String { - format!( - "Team was created and the UI has switched to the team conversation. End this solo turn now. \ - Do not call any `team_*` tools from this solo turn. Reply to the user only with one short \ - handoff in their language. It should mean: the Team is ready, send the next message, and I will continue from there. \ - Do not mention the Team page, solo turn, `team_*` tools, `TeamRun`, or internal tool state. \ - Task summary: {summary}" - ) -} - const NO_ACTIVE_TEAM_RUN_FOR_RUN_SCOPED_WAKE: &str = "no active team run for run-scoped wake"; const GUIDE_NO_ACTIVE_TEAM_RUN_HANDOFF_ERROR: &str = "Team was created, but no TeamRun is active yet. Open the team chat and continue from there."; @@ -224,12 +258,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) @@ -248,6 +276,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 error_response(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. @@ -265,7 +302,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."); } } } @@ -275,9 +312,9 @@ async fn exec_create_team( agents: vec![TeamAgentInput { name: "Leader".to_owned(), role: "leader".to_owned(), - backend: backend.clone(), + backend: None, model: model.clone(), - custom_agent_id: None, + assistant_id: Some(assistant_id), conversation_id: caller_conversation_id, }], workspace: None, @@ -287,21 +324,116 @@ 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()); + } + }; + + 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", - "next_step": build_create_team_handoff_next_step(¶ms.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." }) } +fn extract_assistant_id(value: &serde_json::Value) -> Option { + value + .get("assistant_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) +} + +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); + } + + 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, @@ -385,8 +517,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}) + } } } } @@ -430,37 +578,103 @@ 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_assistant_id( + &self, + assistant_id: &str, + ) -> Result, aionui_db::DbError> { + 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( + &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.assistant_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) + } + } + #[test] - fn create_team_next_step_tells_solo_agent_to_end_turn() { - let next_step = build_create_team_handoff_next_step("Build a research and implementation team"); - - assert!(next_step.contains("Team was created and the UI has switched to the team conversation.")); - assert!(next_step.contains("End this solo turn now.")); - assert!(next_step.contains("Do not call any `team_*` tools from this solo turn.")); - assert!(next_step.contains( - "Reply to the user only with one short handoff in their language. It should mean: the Team is ready, send the next message, and I will continue from there." - )); - assert!( - next_step.contains( - "Do not mention the Team page, solo turn, `team_*` tools, `TeamRun`, or internal tool state." - ) - ); - assert!(next_step.contains("Task summary: Build a research and implementation team")); - assert!( - !next_step.contains("team_spawn_agent"), - "next_step must not name spawn as an immediately available action" - ); - assert!( - !next_step.contains("team_send_message"), - "next_step must not name send_message as an immediately available action" - ); - assert!( - !next_step.contains("tools are now active"), - "next_step must not claim Team tools are active immediately after creation" - ); + fn create_team_next_step_tells_solo_agent_to_use_assistant_first_team_tools() { + let next_step = serde_json::json!({ + "status": "team_created", + "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." + }); + let next_step = next_step["next_step"].as_str().unwrap(); + + assert!(next_step.contains("You are now the team Leader")); + assert!(next_step.contains("team_spawn_agent")); + assert!(next_step.contains("team_send_message")); + assert!(next_step.contains("team_list_assistants")); + assert!(next_step.contains("assistant_id")); + assert!(!next_step.contains("End this solo turn now")); } #[test] @@ -560,6 +774,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(); @@ -601,4 +834,516 @@ mod tests { let body: serde_json::Value = resp.json().await.unwrap(); 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 { + id: "def-guide-models".into(), + assistant_id: "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_id: "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 { + assistant_definition_id: "def-guide-models".into(), + enabled: true, + sort_order: 0, + agent_id_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 { + row: AssistantDefinitionRow { + id: "def-guide-lead".into(), + assistant_id: "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_id: "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 { + assistant_definition_id: "def-guide-lead".into(), + enabled: true, + sort_order: 0, + agent_id_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 next_step = body["next_step"].as_str().expect("next_step 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"); + 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_opens_active_team_run_for_assistant_first_tools() { + let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { + row: AssistantDefinitionRow { + id: "def-guide-teamrun".into(), + assistant_id: "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_id: "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 { + assistant_definition_id: "def-guide-teamrun".into(), + enabled: true, + sort_order: 0, + agent_id_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 { + row: AssistantDefinitionRow { + id: "def-guide-summary".into(), + assistant_id: "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_id: "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 { + assistant_definition_id: "def-guide-summary".into(), + enabled: true, + sort_order: 0, + agent_id_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 { + row: AssistantDefinitionRow { + id: "def-guide-non-assistant-caller".into(), + assistant_id: "assistant-unused".into(), + source: "user".into(), + owner_type: "user".into(), + source_ref: None, + source_version: None, + source_hash: None, + name: "Unused Assistant".into(), + name_i18n: "{}".into(), + description: None, + description_i18n: "{}".into(), + avatar_type: "emoji".into(), + avatar_value: Some("🤖".into()), + agent_id: "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 { + assistant_definition_id: "def-guide-non-assistant-caller".into(), + enabled: true, + sort_order: 0, + agent_id_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: "plain-caller-conv".into(), + user_id: "system_default_user".into(), + name: "Plain Caller".into(), + r#type: "chat".into(), + pinned: false, + pinned_at: None, + source: None, + channel_chat_id: None, + extra: serde_json::json!({ + "workspace": "/tmp/guide-workspace" + }) + .to_string(), + model: None, + status: Some("completed".into()), + created_at: 0, + updated_at: 0, + }) + .await + .expect("seed non-assistant-backed 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": "plain-caller-conv", + "user_id": "system_default_user" + })) + .send() + .await + .expect("call guide create team for non-assistant-backed caller"); + assert_eq!(resp.status(), StatusCode::OK); + + let body: serde_json::Value = resp.json().await.expect("guide create team error response"); + + assert_eq!( + 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 + .list_teams_by_user("system_default_user") + .await + .expect("list teams after rejected create") + .is_empty() + ); + } } diff --git a/crates/aionui-team/src/mcp/server.rs b/crates/aionui-team/src/mcp/server.rs index f4c1b2772..e0e86cf18 100644 --- a/crates/aionui-team/src/mcp/server.rs +++ b/crates/aionui-team/src/mcp/server.rs @@ -10,7 +10,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; @@ -24,7 +24,7 @@ use super::protocol::{ }; use super::tools::{ RenameAgentInput, SendMessageInput, ShutdownAgentInput, SpawnAgentInput, TaskCreateInput, TaskUpdateInput, - all_tool_descriptors_for_role, handle_team_describe_assistant, handle_team_list_models, + all_tool_descriptors_for_role, handle_team_list_models, }; // --------------------------------------------------------------------------- @@ -409,7 +409,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" + ) } } @@ -420,13 +426,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) + } } } @@ -434,6 +450,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, @@ -442,8 +483,8 @@ pub(crate) async fn dispatch_tool( team_id: &str, caller_slot_id: &str, caller_role: TeammateRole, -) -> Result { - super::tools::authorize_tool(caller_role, tool_name)?; +) -> Result { + super::tools::authorize_tool(caller_role, tool_name).map_err(ToolCallError::from_message)?; match tool_name { "team_send_message" => exec_send_message(arguments, scheduler, service, team_id, caller_slot_id).await, @@ -456,24 +497,72 @@ 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).await, - _ => Err(format!("Unknown tool: {tool_name}")), + "team_describe_assistant" => exec_describe_assistant(arguments, service).await, + _ => Err(ToolCallError::from_message(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(ToolCallError::from_message( + "team_list_assistants does not accept arguments", + )); } + let service = service + .upgrade() + .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| ToolCallError::from_message(format!("Serialization error: {e}"))) } -async fn exec_list_models(args: &Value, service: &Weak) -> Result { - let agent_type_filter = args.get("agent_type").and_then(Value::as_str); +async fn exec_list_models(args: &Value, service: &Weak) -> Result { + if args.get("backend").is_some() { + return Err(ToolCallError::from_message( + "backend is no longer accepted; use assistant_id", + )); + } + if args.get("agent_type").is_some() { + 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); 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| 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) -> Result { - Ok(handle_team_describe_assistant(args)) +async fn exec_describe_assistant(args: &Value, service: &Weak) -> Result { + if args.get("custom_agent_id").is_some() { + return Err(ToolCallError::from_message( + "custom_agent_id is no longer accepted; use assistant_id", + )); + } + let assistant_id = args + .get("assistant_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .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(|| ToolCallError::from_message("Team service not available"))?; + + service + .describe_assistant(assistant_id, locale) + .await + .map_err(|error| ToolCallError::from_message(error.to_string())) } // --------------------------------------------------------------------------- @@ -500,8 +589,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" { @@ -532,7 +622,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) @@ -553,12 +643,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 @@ -576,13 +668,13 @@ async fn exec_send_message( let result = 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()))?; target_results.push(result); } - let response = build_send_message_queued_response(target_results)?; + let response = build_send_message_queued_response(target_results).map_err(ToolCallError::from_message)?; - serde_json::to_string(&response).map_err(|e| format!("Serialization error: {e}")) + serde_json::to_string(&response).map_err(|e| ToolCallError::from_message(format!("Serialization error: {e}"))) } fn build_send_message_queued_response( @@ -606,48 +698,59 @@ 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(ToolCallError::from_message( + "backend is no longer accepted; use assistant_id", + )); + } + if args.get("agent_type").is_some() { + 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(|| 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. 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); - - // 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, - custom_agent_id: input.custom_agent_id, + assistant_id: Some(assistant_id), model: input.model, }; 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(), @@ -658,13 +761,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(), @@ -676,13 +780,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| { @@ -697,10 +804,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() @@ -723,7 +830,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( @@ -731,24 +838,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)) @@ -761,20 +871,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 .shutdown_agent_in_session(team_id, caller_slot_id, &target_slot_id, input.reason) .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)) } @@ -886,7 +999,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 + } } } _ => { @@ -972,7 +1101,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:?}" ); } @@ -981,12 +1110,12 @@ 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!( - err.contains("Invalid params"), + err.message.contains("Invalid params"), "must surface Invalid params for JSON deserialize failure, got {err:?}" ); } @@ -1001,33 +1130,91 @@ 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; 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:?}" ); } - /// 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.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] + 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.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] + 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.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] + 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.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] + 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.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] + 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"), - "legacy 'backend' alias must parse through to service-upgrade step, got {err:?}" + 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/mcp/tools.rs b/crates/aionui-team/src/mcp/tools.rs index 0ed8b8cd4..cbd0be288 100644 --- a/crates/aionui-team/src/mcp/tools.rs +++ b/crates/aionui-team/src/mcp/tools.rs @@ -6,7 +6,8 @@ use crate::scheduler::SchedulerAction; use crate::types::TeammateRole; pub use aionui_team_prompts::tools::{ - TEAM_DESCRIBE_ASSISTANT_DESCRIPTION, TEAM_LIST_MODELS_DESCRIPTION, TEAM_SPAWN_AGENT_DESCRIPTION, + TEAM_DESCRIBE_ASSISTANT_DESCRIPTION, TEAM_LIST_ASSISTANTS_DESCRIPTION, TEAM_LIST_MODELS_DESCRIPTION, + TEAM_SPAWN_AGENT_DESCRIPTION, }; // --------------------------------------------------------------------------- @@ -51,22 +52,16 @@ 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 -/// 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`. +/// 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 backend: Option, - #[serde(default)] - pub agent_type: Option, - #[serde(default)] - pub custom_agent_id: Option, + #[serde(alias = "assistantId")] + pub assistant_id: Option, #[serde(default)] pub model: Option, } @@ -116,7 +111,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" => { @@ -127,30 +122,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() - .or(input.backend.clone()) - .ok_or_else(|| "Missing 'agent_type' (or legacy 'backend') 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}"))?; @@ -176,6 +148,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}")), @@ -187,16 +160,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"] } ] @@ -205,17 +178,17 @@ 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; 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 { @@ -244,8 +217,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; @@ -253,8 +226,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; @@ -294,19 +267,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 }) -} - -/// 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() + json!({ "backends": backends }) } // --------------------------------------------------------------------------- @@ -319,7 +286,7 @@ mod tests { #[test] fn all_descriptors_count() { - assert_eq!(all_tool_descriptors().len(), 10); + assert_eq!(all_tool_descriptors().len(), 11); } #[test] @@ -328,7 +295,7 @@ mod tests { let mut names: Vec<&str> = descs.iter().map(|d| d.name.as_str()).collect(); names.sort(); names.dedup(); - assert_eq!(names.len(), 10); + assert_eq!(names.len(), 11); } #[test] @@ -359,7 +326,7 @@ 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") @@ -367,17 +334,17 @@ mod tests { 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" + props.contains_key("assistant_id"), + "schema must expose 'assistant_id' field" ); assert!( - props.contains_key("custom_agent_id"), - "schema must expose 'custom_agent_id' field" + !props.contains_key("agent_type"), + "assistant-first schema must not expose 'agent_type'" ); } #[test] - fn team_spawn_agent_schema_required_is_only_name() { + fn team_spawn_agent_schema_requires_name_and_assistant_id() { let desc = all_tool_descriptors() .into_iter() .find(|d| d.name == "team_spawn_agent") @@ -385,9 +352,10 @@ mod tests { let required = desc.input_schema["required"].as_array().unwrap(); let names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); assert!(names.contains(&"name"), "name must be required"); + assert!(names.contains(&"assistant_id"), "assistant_id 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" ); } @@ -403,30 +371,27 @@ mod tests { } #[test] - fn parse_spawn_agent_lead_ok() { - let args = json!({"name": "Helper", "backend": "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", "backend": "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() { - let args = json!({"name": "X", "backend": "malicious"}); + 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] @@ -473,14 +438,11 @@ mod tests { } #[test] - fn parse_spawn_with_explicit_role() { - let args = json!({"name": "W", "role": "worker", "backend": "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] @@ -524,7 +486,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() { @@ -535,14 +497,27 @@ 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.") + 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() @@ -552,12 +527,104 @@ 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 + .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 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!({ + "name": "Preset helper", + "assistant_id": "word-creator", + })) + .unwrap(); + assert_eq!(input.assistant_id.as_deref(), Some("word-creator")); + } + + #[test] + 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(); + assert!(assistant_desc.starts_with("Assistant ID to spawn")); + assert!(!props.contains_key("agent_type")); + assert!(!props.contains_key("backend")); + } + + #[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("After confirming a match, call team_spawn_agent with the same custom_agent_id.") + .contains("recommended assistant, and recommended model") ); + 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 ---- @@ -565,14 +632,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")); @@ -586,11 +653,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")); @@ -605,11 +672,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() @@ -621,17 +688,17 @@ 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"}]"#), ]; 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"]); } @@ -643,11 +710,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")); @@ -679,6 +746,17 @@ 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, + command_override: None, + env_override: None, created_at: 0, updated_at: 0, } @@ -712,11 +790,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!( @@ -726,7 +804,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(); @@ -739,11 +817,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"]); } @@ -753,11 +831,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() @@ -792,11 +870,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() @@ -806,10 +884,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/prompts/mod.rs b/crates/aionui-team/src/prompts/mod.rs index 348c56ba0..002f21ec5 100644 --- a/crates/aionui-team/src/prompts/mod.rs +++ b/crates/aionui-team/src/prompts/mod.rs @@ -1,5 +1,6 @@ pub mod team_guide; +pub use aionui_team_prompts::AvailableAssistant; pub use team_guide::{TEAM_GUIDE_PROMPT_TEMPLATE, build_team_guide_prompt}; use std::collections::HashMap; @@ -30,25 +31,22 @@ fn to_prompt_agent(agent: &TeamAgent) -> aionui_team_prompts::TeamPromptAgent { /// A one-line `Team: ""` header is prepended so the leader knows which /// team it belongs to. /// -/// `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 { +/// `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 prompt_members: Vec<_> = members.iter().map(to_prompt_agent).collect(); - let agent_types: Vec<_> = available_agent_types - .iter() - .map(|(backend, display)| aionui_team_prompts::AvailableAgentType { - agent_type: backend.clone(), - display_name: display.clone(), - }) - .collect(); let renamed: HashMap = HashMap::new(); let body = aionui_team_prompts::build_lead_prompt(&aionui_team_prompts::LeadPromptParams { team_name, teammates: &prompt_members, - available_agent_types: &agent_types, - available_assistants: &[], + available_agent_types: &[], + available_assistants, renamed_agents: &renamed, team_workspace: None, }); @@ -160,7 +158,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, @@ -175,7 +173,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, @@ -215,26 +213,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:")); @@ -243,12 +243,12 @@ 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")); - 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")); @@ -277,40 +277,42 @@ 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 Assistants for Spawning")); + assert!(prompt.contains("- `word-creator` (Word Creator) — Drafts Word documents")); + assert!(prompt.contains("skills: docx, formatting")); + assert!(prompt.contains("Pass the assistant's ID as `assistant_id`")); + assert!(!prompt.contains("backend: claude")); } #[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 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}" ); + assert!(!prompt.contains("assistant or backend")); + assert!(!prompt.contains("Available Generic Backends")); } // -- Teammate prompt ------------------------------------------------------ diff --git a/crates/aionui-team/src/prompts/team_guide.rs b/crates/aionui-team/src/prompts/team_guide.rs index 6c8cded59..01d271962 100644 --- a/crates/aionui-team/src/prompts/team_guide.rs +++ b/crates/aionui-team/src/prompts/team_guide.rs @@ -14,39 +14,45 @@ mod tests { use super::*; #[test] - fn team_guide_prompt_hands_off_after_create_team() { + fn team_guide_prompt_uses_team_tools_after_create_team() { let prompt = build_team_guide_prompt("claude", None); assert!(prompt.contains("aion_create_team")); assert!(prompt.contains("aion_list_models")); - assert!(prompt.contains("hand off to the created Team conversation")); - assert!(prompt.contains("Do NOT call `team_*` tools from this solo Guide MCP session.")); - assert!(!prompt.contains("Immediately")); - assert!(!prompt.contains( + assert!(prompt.contains("only use returned assistant_id values with `team_spawn_agent`")); + assert!(prompt.contains( "use team tools (`team_spawn_agent`, `team_send_message`, `team_members`, `team_task_create`, etc.) to manage your team" )); + assert!(!prompt.contains("Immediately")); + assert!(!prompt.contains("hand off to the created Team conversation")); } #[test] 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] 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) |")); assert!(!prompt.contains("()")); } + + #[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) |")); + } } diff --git a/crates/aionui-team/src/provisioning.rs b/crates/aionui-team/src/provisioning.rs index 54a8bd876..ee41331b1 100644 --- a/crates/aionui-team/src/provisioning.rs +++ b/crates/aionui-team/src/provisioning.rs @@ -4,20 +4,26 @@ 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::{ + IAgentMetadataRepository, IAssistantDefinitionRepository, IAssistantOverlayRepository, IProviderRepository, + ITeamRepository, UpdateTeamParams, +}; use async_trait::async_trait; 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, conversation_port: Arc, } @@ -41,7 +47,7 @@ struct NewAgentProvisioning { role: TeammateRole, backend: String, model: String, - custom_agent_id: Option, + assistant_id: Option, workspace: Option, } @@ -52,14 +58,15 @@ pub(crate) struct PersistSpawnedAgentRequest { pub name: String, pub backend: String, pub model: String, - pub custom_agent_id: Option, + pub assistant_id: Option, } 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, pub extra: serde_json::Value, } @@ -85,6 +92,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>; @@ -102,13 +111,26 @@ pub trait TeamConversationProvisioningPort: Send + Sync { } impl TeamAgentProvisioner { + fn effective_assistant_id(assistant_id: Option<&str>) -> Option { + assistant_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + } + pub(crate) fn new( repo: Arc, + agent_metadata_repo: Arc, + assistant_definition_repo: Arc, + assistant_overlay_repo: Arc, provider_repo: Arc, conversation_port: Arc, ) -> Self { Self { repo, + agent_metadata_repo, + assistant_definition_repo, + assistant_overlay_repo, provider_repo, conversation_port, } @@ -131,6 +153,10 @@ 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()); + let leader_backend = self + .resolve_requested_backend(leader_input.backend.as_deref(), leader_assistant_id.as_deref()) + .await?; let leader_conversation = self .create_or_adopt_conversation( user_id, @@ -138,9 +164,9 @@ impl TeamAgentProvisioner { &leader_slot_id, leader_role, &leader_input.name, - &leader_input.backend, + &leader_backend, &leader_input.model, - leader_input.custom_agent_id.as_deref(), + leader_assistant_id.as_deref(), leader_input.conversation_id.as_deref(), shared_workspace, ) @@ -164,9 +190,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(), - custom_agent_id: leader_input.custom_agent_id.clone(), + assistant_id: leader_assistant_id, status: None, conversation_type: None, cli_path: None, @@ -175,6 +201,10 @@ 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()); + let backend = self + .resolve_requested_backend(input.backend.as_deref(), assistant_id.as_deref()) + .await?; let conversation = self .create_or_adopt_conversation( user_id, @@ -182,9 +212,9 @@ impl TeamAgentProvisioner { &slot_id, role, &input.name, - &input.backend, + &backend, &input.model, - input.custom_agent_id.as_deref(), + assistant_id.as_deref(), input.conversation_id.as_deref(), Some(&team_workspace), ) @@ -194,9 +224,9 @@ impl TeamAgentProvisioner { name: input.name.clone(), role, conversation_id: conversation.conversation_id, - backend: input.backend.clone(), + backend, model: input.model.clone(), - custom_agent_id: input.custom_agent_id.clone(), + assistant_id, status: None, conversation_type: None, cli_path: None, @@ -230,6 +260,10 @@ 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()); + let backend = self + .resolve_requested_backend(req.backend.as_deref(), assistant_id.as_deref()) + .await?; let agent = self .provision_new_agent(NewAgentProvisioning { user_id: user_id.to_owned(), @@ -237,9 +271,9 @@ impl TeamAgentProvisioner { slot_id: generate_id(), name: req.name, role, - backend: req.backend, + backend, model: req.model, - custom_agent_id: req.custom_agent_id, + assistant_id, workspace: Some(workspace), }) .await?; @@ -248,6 +282,34 @@ impl TeamAgentProvisioner { Ok(agent) } + async fn resolve_requested_backend( + &self, + requested_backend: Option<&str>, + assistant_id: Option<&str>, + ) -> Result { + 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_assistant_id(assistant_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()) + .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 { + return Err(TeamError::InvalidRequest( + "backend is required when assistant_id is absent".into(), + )); + }; + Ok(requested_backend.to_owned()) + } + pub(crate) async fn persist_spawned_agent(&self, req: PersistSpawnedAgentRequest) -> Result { let row = self .repo @@ -265,7 +327,7 @@ impl TeamAgentProvisioner { role: TeammateRole::Teammate, backend: req.backend, model: req.model, - custom_agent_id: req.custom_agent_id, + assistant_id: req.assistant_id, workspace: Some(workspace), }) .await?; @@ -346,7 +408,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(), ) @@ -358,7 +420,7 @@ impl TeamAgentProvisioner { conversation_id: conversation.conversation_id, backend: input.backend, model: input.model, - custom_agent_id: input.custom_agent_id, + assistant_id: input.assistant_id, status: None, conversation_type: None, cli_path: None, @@ -375,12 +437,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 @@ -429,9 +491,10 @@ 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), extra, }) .await?; @@ -511,7 +574,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!({ @@ -524,9 +587,8 @@ 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()); } if let Some(workspace) = workspace { inherit_team_workspace(&mut extra, workspace); diff --git a/crates/aionui-team/src/routes.rs b/crates/aionui-team/src/routes.rs index d706b65f4..856e19e57 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::Conflict(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,38 @@ 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 slot_busy_maps_to_conflict() { let api_error: ApiError = TeamError::SlotBusy("lead-1".into()).into(); 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.rs b/crates/aionui-team/src/service.rs index 9c968aecd..878715e2b 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, ConversationStatus, 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}; @@ -43,6 +47,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, @@ -78,6 +84,8 @@ impl TeamSessionService { 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, @@ -92,6 +100,8 @@ impl TeamSessionService { Arc::new_cyclic(|weak| Self { repo, agent_metadata_repo, + assistant_definition_repo, + assistant_overlay_repo, provider_repo, conversation_port, projection_store, @@ -112,6 +122,9 @@ 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(), self.conversation_port.clone(), ) @@ -126,6 +139,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 @@ -973,7 +993,7 @@ impl TeamSessionService { Ok(()) } - pub(crate) async fn send_agent_message_from_agent( + pub async fn send_agent_message_from_agent( &self, team_id: &str, from_slot_id: &str, @@ -1032,6 +1052,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); + } + /// Friendly pre-check used by Guide MCP to return handoff copy before invoking /// run-scoped team tools. This is not a concurrency guarantee; any operation /// that writes mailbox, projection, scheduler, spawn, shutdown, or wake state @@ -1049,6 +1082,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, 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..82cd5d70f --- /dev/null +++ b/crates/aionui-team/src/service/describe_support.rs @@ -0,0 +1,115 @@ +use std::collections::HashMap; +use std::fmt::Write; + +use crate::error::TeamError; +use crate::service::TeamSessionService; +use crate::service::spawn_support::resolve_runtime_backend; +use aionui_db::models::AssistantDefinitionRow; + +impl TeamSessionService { + pub(crate) async fn describe_assistant( + &self, + assistant_id: &str, + locale: Option<&str>, + ) -> Result { + let definition = self + .assistant_definition_repo + .get_by_assistant_id(assistant_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()) + .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, + &effective_backend, + locale.unwrap_or("en-US"), + )) + } +} + +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); + + 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_id); + 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 `assistant_id=\"{}\"`.", + definition.assistant_id + ); + 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/service/response_builder.rs b/crates/aionui-team/src/service/response_builder.rs index 023f6072c..d8807a47f 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, }) @@ -36,11 +36,15 @@ 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? - && row.icon.is_some() + if let Some(assistant_id) = agent.assistant_id.as_deref() + && let Some(definition) = self.assistant_definition_repo.get_by_assistant_id(assistant_id).await? + && let Some(icon) = assistant_icon( + definition.assistant_id.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_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_id}/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/src/service/spawn_support.rs b/crates/aionui-team/src/service/spawn_support.rs index f9135734d..4eb498158 100644 --- a/crates/aionui-team/src/service/spawn_support.rs +++ b/crates/aionui-team/src/service/spawn_support.rs @@ -2,6 +2,12 @@ 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 aionui_db::{IAgentMetadataRepository, resolve_agent_binding_from_rows}; +use std::collections::HashMap; +use std::sync::Arc; + +use crate::prompts::AvailableAssistant; use crate::provisioning::PersistSpawnedAgentRequest; @@ -60,7 +66,66 @@ 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, + assistant_id: Option<&str>, + requested_model: Option<&str>, + fallback_backend: &str, + fallback_model: &str, + ) -> Result<(String, String), TeamError> { + if let Some(assistant_id) = assistant_id.map(str::trim).filter(|value| !value.is_empty()) { + let definition = self + .assistant_definition_repo + .get_by_assistant_id(assistant_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()) + .filter(|value| !value.trim().is_empty()) + .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()) + .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 = 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. @@ -86,73 +151,135 @@ 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 Ok(agent_rows) = self.agent_metadata_repo.list_all().await else { + return Vec::new(); }; - let mut result: Vec<(String, String)> = Vec::new(); - for row in &rows { - if !row.enabled { + + 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(); + + 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.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_agent_id = overlay + .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 + .unwrap_or_else(|_| effective_agent_id.to_owned()); + + 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.as_str()) && row.agent_source != "custom" + }) + .or_else(|| { + agent_rows + .iter() + .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; + 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_id.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. /// 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_id) => { + let definition = self + .assistant_definition_repo + .get_by_assistant_id(assistant_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, + 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, }; 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. @@ -169,6 +296,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()) @@ -227,12 +357,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)] @@ -241,6 +367,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_assistant_id(&self, assistant_id: &str) -> Result, DbError> { + Ok((self.row.assistant_id == assistant_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( + &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.assistant_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() { @@ -280,7 +523,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(); @@ -295,7 +538,7 @@ mod tests { name: "Spawned".into(), backend: "acp".into(), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, }) .await .unwrap(); @@ -308,4 +551,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 { + id: "def-1".into(), + assistant_id: "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_id: "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 { + assistant_definition_id: "def-1".into(), + enabled: true, + sort_order: 0, + agent_id_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, "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 45b64e83d..e37f7bf7c 100644 --- a/crates/aionui-team/src/session.rs +++ b/crates/aionui-team/src/session.rs @@ -26,11 +26,9 @@ use crate::provisioning::PersistSpawnedAgentRequest; use crate::scheduler::{TeammateManager, normalize_name}; use crate::service::TeamSessionService; use crate::task_board::TaskBoard; -#[cfg(test)] -use crate::team_run::WakeRecordDecision; use crate::team_run::{ ChildCancelTarget, RecoveryBacklogResult, RecoveryWakeCandidate, TeamRunManager, TeamRunWakeAcquireOutcome, - target_role_for, + WakeRecordDecision, target_role_for, }; use crate::types::{MailboxMessageType, Team, TeamAgent, TeammateRole, TeammateStatus}; use crate::wake::TeamWakeSource; @@ -59,7 +57,7 @@ pub struct WakeInput { } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct AgentMessageQueueResult { +pub struct AgentMessageQueueResult { pub team_run_id: String, pub delivery: TeamSendMessageDelivery, pub target: TeamSendMessageTargetQueueState, @@ -70,11 +68,15 @@ pub(crate) struct AgentMessageQueueResult { #[derive(Debug, Clone)] pub struct SpawnAgentRequest { pub name: String, - pub agent_type: Option, - pub custom_agent_id: Option, + pub assistant_id: Option, pub model: Option, } +enum SpawnWakePlan { + RunScoped(TeamRunTargetRole), + MailboxOnly, +} + pub struct TeamSession { team: Team, scheduler: Arc, @@ -285,24 +287,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 => { @@ -767,7 +759,28 @@ impl TeamSession { Ok(()) } - #[cfg(test)] + 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, @@ -1271,11 +1284,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 @@ -1307,47 +1319,42 @@ impl TeamSession { return Err(TeamError::DuplicateAgentName(requested_name)); } - // Step 3: backend capability check. Hard whitelist passes immediately; - // otherwise query persisted agent_capabilities for MCP support. - let backend = req - .agent_type + let assistant_id = req + .assistant_id .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, - }; - if !capable { - return Err(TeamError::BackendNotAllowed(backend)); - } - } + .filter(|value| !value.is_empty()) + .ok_or_else(|| TeamError::InvalidRequest("spawn_agent.assistant_id is required".into()))?; - // 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()), - }; - let new_slot_id = generate_id(); - let outcome = self - .team_run_manager - .acquire_run_scoped_wake(&new_slot_id, TeamRunTargetRole::Teammate, TeamWakeSource::SpawnWelcome) + + // 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( + Some(assistant_id), + req.model.as_deref(), + caller.backend.as_str(), + caller.model.as_str(), + ) .await?; - let TeamRunWakeAcquireOutcome::Accepted(lease) = outcome else { - return Err(TeamError::InvalidRequest("spawn welcome wake was suppressed".into())); - }; - let new_agent = match service + // Step 4: backend capability check. Hard whitelist passes immediately; + // otherwise query persisted agent_capabilities for MCP support. + if !crate::guide::capability::TEAM_CAPABLE_BACKENDS.contains(&backend.as_str()) { + let capable = service.is_backend_team_capable(&backend).await; + if !capable { + return Err(TeamError::BackendNotAllowed(backend)); + } + } + + // Step 5: DB side-effects (new conversation + persisted agent slot). + let new_slot_id = generate_id(); + let new_agent = service .persist_spawned_agent(PersistSpawnedAgentRequest { team_id: self.team.id.clone(), user_id: self.user_id.clone(), @@ -1355,19 +1362,9 @@ impl TeamSession { name: requested_name, backend, model, - custom_agent_id: req.custom_agent_id.clone(), + assistant_id: Some(assistant_id.to_owned()), }) - .await - { - Ok(agent) => agent, - Err(err) => { - let _ = self - .team_run_manager - .abort_operation_lease(&lease.lease_id, "spawn_persistence_failed") - .await; - return Err(err); - } - }; + .await?; // Step 5: attach to the in-memory scheduler so wake-from-lead finds // the new slot immediately. @@ -1397,25 +1394,37 @@ impl TeamSession { } else { let _ = self.scheduler.remove_agent(&new_agent.slot_id).await; } - let _ = self - .team_run_manager - .abort_operation_lease(&lease.lease_id, "spawn_welcome_mailbox_write_failed") - .await; return Err(err); } }; - self.team_run_manager - .commit_operation_lease(&lease.lease_id, Some(welcome_message.id.clone())) - .await?; - let spawn_welcome_role = lease.role.clone(); - info!( - team_id = %self.team.id, - slot_id = %new_agent.slot_id, - target_role = ?spawn_welcome_role, - wake_source = %lease.wake_source, - "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 @@ -1466,13 +1475,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); + } + } }); } @@ -1852,7 +1867,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, @@ -1864,7 +1879,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, @@ -2810,7 +2825,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, @@ -2930,8 +2945,7 @@ mod tests { fn sample_spawn_req() -> SpawnAgentRequest { SpawnAgentRequest { name: "Helper".into(), - agent_type: None, - custom_agent_id: None, + assistant_id: Some("word-creator".into()), model: None, } } @@ -3653,11 +3667,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), - custom_agent_id: None, + assistant_id: assistant_id.map(str::to_owned), model: None, } } @@ -3674,66 +3687,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(); } @@ -3742,7 +3704,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!( @@ -3757,7 +3719,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) @@ -3773,7 +3735,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) @@ -3801,7 +3763,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); @@ -3817,7 +3779,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/src/team_run.rs b/crates/aionui-team/src/team_run.rs index 3024001a1..3297572ad 100644 --- a/crates/aionui-team/src/team_run.rs +++ b/crates/aionui-team/src/team_run.rs @@ -15,9 +15,7 @@ use crate::events::{ TEAM_RUN_ACCEPTED_EVENT, TEAM_RUN_CANCELLED_EVENT, TEAM_RUN_COMPLETED_EVENT, TEAM_RUN_FAILED_EVENT, TEAM_RUN_STARTED_EVENT, TEAM_RUN_UPDATED_EVENT, TeamEventEmitter, }; -use crate::slot_wake_gate::SlotWakeGate; -#[cfg(test)] -use crate::slot_wake_gate::WakeGateDecision; +use crate::slot_wake_gate::{SlotWakeGate, WakeGateDecision}; use crate::types::TeammateRole; use crate::wake::TeamWakeSource; @@ -74,7 +72,6 @@ pub enum ChildCancelTarget { Starting(StartingChildReservation), } -#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum WakeRecordDecision { Recorded, @@ -1055,7 +1052,6 @@ impl TeamRunManager { .map(|_| ()) } - #[cfg(test)] pub(crate) async fn record_or_suppress_wake( &self, slot_id: &str, diff --git a/crates/aionui-team/src/test_utils.rs b/crates/aionui-team/src/test_utils.rs index 017e8088d..4ad1e41c0 100644 --- a/crates/aionui-team/src/test_utils.rs +++ b/crates/aionui-team/src/test_utils.rs @@ -205,15 +205,16 @@ 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, 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, MessagePageParams, MessagePageResult, MessageRowUpdate, MessageSearchRow, - UpdateTeamParams, + ConversationFilters, ConversationRowUpdate, DbError, IAgentMetadataRepository, IAssistantDefinitionRepository, + IAssistantOverlayRepository, IConversationRepository, IProviderRepository, ITeamRepository, MessagePageParams, + MessagePageResult, MessageRowUpdate, MessageSearchRow, UpdateTeamParams, }; use aionui_realtime::EventBroadcaster; use async_trait::async_trait; @@ -544,7 +545,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, @@ -577,6 +578,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 @@ -743,6 +756,23 @@ 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 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) } @@ -752,6 +782,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_assistant_id(&self, _assistant_id: &str) -> Result, DbError> { + Ok(None) + } + + async fn get_by_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] @@ -840,6 +927,45 @@ 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, + 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) 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, @@ -872,9 +998,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, conversation_id: None, }], workspace: None, diff --git a/crates/aionui-team/src/types.rs b/crates/aionui-team/src/types.rs index 859a00e5e..7c7dca5f0 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")] @@ -116,13 +123,15 @@ 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(), - custom_agent_id: self.custom_agent_id.clone(), + assistant_id: self.assistant_id.clone(), status: self.status.map(|s| s.to_string()), pending_confirmations: 0, } @@ -282,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, } @@ -511,7 +520,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, @@ -521,7 +530,7 @@ mod tests { assert_eq!(resp.role, "lead"); assert!(resp.icon.is_none()); assert_eq!(resp.status.as_deref(), Some("working")); - assert_eq!(resp.custom_agent_id.as_deref(), Some("custom-1")); + assert_eq!(resp.assistant_id.as_deref(), Some("custom-1")); } #[test] @@ -533,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, @@ -553,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, @@ -572,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, @@ -580,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] @@ -601,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 -------------------------------------------------------- @@ -614,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, @@ -653,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, @@ -665,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); } diff --git a/crates/aionui-team/tests/e2e_smoke.rs b/crates/aionui-team/tests/e2e_smoke.rs index 8d3951622..ddfc18a8e 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, @@ -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. diff --git a/crates/aionui-team/tests/e2e_team_flow.rs b/crates/aionui-team/tests/e2e_team_flow.rs index bc22b8b52..c6aff8d7c 100644 --- a/crates/aionui-team/tests/e2e_team_flow.rs +++ b/crates/aionui-team/tests/e2e_team_flow.rs @@ -548,7 +548,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, @@ -560,7 +560,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, @@ -765,7 +765,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; @@ -781,12 +781,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(); } @@ -1078,7 +1079,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, @@ -1184,7 +1185,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, @@ -1432,7 +1433,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 ece1a5fc2..927c0a51b 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, @@ -223,7 +223,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(); } @@ -283,12 +283,12 @@ 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; let names = list_tools(&mut stream, 10).await; - assert_eq!(names.len(), 10); + assert_eq!(names.len(), 11); assert!(names.contains(&"team_send_message".to_owned())); assert!(names.contains(&"team_spawn_agent".to_owned())); @@ -298,6 +298,7 @@ async fn tools_list_returns_all_10_tools() { assert!(names.contains(&"team_members".to_owned())); assert!(names.contains(&"team_rename_agent".to_owned())); assert!(names.contains(&"team_shutdown_agent".to_owned())); + assert!(names.contains(&"team_list_assistants".to_owned())); assert!(names.contains(&"team_list_models".to_owned())); assert!(names.contains(&"team_describe_assistant".to_owned())); @@ -319,6 +320,7 @@ async fn mcp_tools_list_filters_lead_only_tools() { assert!(names.contains(&"team_task_update".to_owned())); assert!(names.contains(&"team_task_list".to_owned())); assert!(names.contains(&"team_members".to_owned())); + assert!(names.contains(&"team_list_assistants".to_owned())); assert!(names.contains(&"team_list_models".to_owned())); assert!(names.contains(&"team_describe_assistant".to_owned())); @@ -470,7 +472,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; @@ -485,7 +487,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; @@ -499,9 +501,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/prompts_events_integration.rs b/crates/aionui-team/tests/prompts_events_integration.rs index 2b9143dce..8d3924c7e 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, }; @@ -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, @@ -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"), @@ -468,8 +486,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 ----------------------------------------------- @@ -549,7 +567,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/scheduler_integration.rs b/crates/aionui-team/tests/scheduler_integration.rs index 808e5df4c..c2b3190c5 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, @@ -381,7 +381,7 @@ async fn we2_add_agent_broadcasts_spawned() { let events = h.broadcaster.events_by_name("team.agentSpawned"); assert_eq!(events.len(), 1); assert_eq!(events[0].data["team_id"], "team-1"); - assert!(events[0].data["agent"].is_object()); + assert!(events[0].data["assistant"].is_object()); } // -- WE-3: Remove agent broadcasts team.agentRemoved ---------------------- diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index f9742da95..ec0b36f32 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -13,14 +13,18 @@ use aionui_ai_agent::{AgentError, IWorkerTaskManager, WorkerTaskManagerImpl}; use aionui_api_types::{AcpBuildExtra, AddAgentRequest, CreateTeamRequest, TeamAgentInput, WebSocketMessage}; use aionui_common::{AgentKillReason, AgentType, ConversationStatus, PaginatedResult, ProviderWithModel}; use aionui_db::models::{ - AgentMetadataRow, ConversationRow, MessageRow, UpdateAgentHandshakeParams, UpsertAgentMetadataParams, + AgentMetadataRow, AssistantDefinitionRow, AssistantOverlayRow, ConversationRow, MessageRow, + UpdateAgentAvailabilitySnapshotParams, UpdateAgentHandshakeParams, UpsertAgentMetadataParams, + UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, }; use aionui_db::{ - ConversationFilters, ConversationRowUpdate, DbError, IAgentMetadataRepository, IConversationRepository, - IProviderRepository, ITeamRepository, MessagePageParams, MessagePageResult, MessageRowUpdate, MessageSearchRow, + ConversationFilters, ConversationRowUpdate, DbError, IAgentMetadataRepository, IAssistantDefinitionRepository, + IAssistantOverlayRepository, IConversationRepository, IProviderRepository, ITeamRepository, MessagePageParams, + MessagePageResult, MessageRowUpdate, MessageSearchRow, }; use aionui_realtime::EventBroadcaster; +use aionui_team::TeamSessionService; use aionui_team::ports::{ AgentTurnCancellationPort, AgentTurnExecutionError, AgentTurnExecutionPort, AgentTurnOutcome, AgentTurnRequest, AgentTurnStarted, AgentTurnStatus, TeamConversationBindingLookup, TeamConversationLookupPort, @@ -30,7 +34,6 @@ use aionui_team::{ TeamConversationAdoptRequest, TeamConversationCreateRequest, TeamConversationCreateResult, TeamConversationProvisioningPort, TeamProjectionMessageStore, }; -use aionui_team::{TeamError, TeamSessionService}; use common::MockTeamRepo; // --------------------------------------------------------------------------- @@ -302,7 +305,8 @@ impl FakeConversationPorts { fn apply_preset_snapshot(&self, extra: &mut serde_json::Value) { let Some(preset_id) = extra - .get("preset_assistant_id") + .get("assistant_id") + .or_else(|| extra.get("preset_assistant_id")) .and_then(serde_json::Value::as_str) .map(str::to_owned) else { @@ -331,6 +335,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 @@ -352,7 +361,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, @@ -409,6 +418,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 @@ -839,6 +860,21 @@ impl IAgentMetadataRepository for StubAgentMetadataRepo { ) -> Result, DbError> { Ok(None) } + async fn update_availability_snapshot( + &self, + _id: &str, + _params: &UpdateAgentAvailabilitySnapshotParams<'_>, + ) -> 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) } @@ -1135,6 +1171,118 @@ 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_assistant_id(&self, _assistant_id: &str) -> Result, DbError> { + Ok(None) + } + + async fn get_by_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) + } +} + +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_assistant_id(&self, assistant_id: &str) -> Result, DbError> { + Ok((self.row.assistant_id == assistant_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( + &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.assistant_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())) } @@ -1184,6 +1332,50 @@ 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, + 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_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, @@ -1206,6 +1398,25 @@ fn setup_with_ports_team_repo_and_conversation_repo( Arc, Arc, Arc, +) { + setup_with_ports_metadata_assistants_and_conversation_repo( + factory, + agent_metadata_repo, + Arc::new(EmptyAssistantDefinitionRepo), + Arc::new(EmptyAssistantOverlayRepo), + ) +} + +fn setup_with_ports_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(); @@ -1221,6 +1432,8 @@ fn setup_with_ports_team_repo_and_conversation_repo( let svc = TeamSessionService::new( team_repo_dyn, agent_metadata_repo, + assistant_definition_repo, + assistant_overlay_repo, provider_repo, conversation_port, projection_store, @@ -1256,6 +1469,8 @@ fn setup_with_recording_turn_port() -> ( let svc = TeamSessionService::new( team_repo_dyn, Arc::new(StubAgentMetadataRepo::empty()), + Arc::new(EmptyAssistantDefinitionRepo), + Arc::new(EmptyAssistantOverlayRepo), provider_repo, conversation_port, projection_store, @@ -1288,7 +1503,7 @@ async fn ensure_session_recovery_drain_runs_agent_turn_with_team_run_id() { ) .await .expect("create team"); - let lead_slot_id = created.lead_agent_id.clone().expect("lead"); + let lead_slot_id = created.leader_assistant_id.clone().expect("lead"); svc.stop_session("user1", &created.id) .await .expect("stop auto-started session"); @@ -1342,7 +1557,7 @@ async fn teammate_first_wake_uses_canonical_prompt_at_service_boundary() { ) .await .expect("create team"); - let worker_slot_id = created.agents[1].slot_id.clone(); + let worker_slot_id = created.assistants[1].slot_id.clone(); svc.stop_session("user1", &created.id) .await .expect("stop auto-started session"); @@ -1412,7 +1627,7 @@ async fn ensure_session_does_not_run_self_message_only_recovery_turn() { ) .await .expect("create team"); - let lead_slot_id = created.lead_agent_id.clone().expect("lead"); + let lead_slot_id = created.leader_assistant_id.clone().expect("lead"); svc.stop_session("user1", &created.id) .await .expect("stop auto-started session"); @@ -1458,6 +1673,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, + command_override: None, + env_override: None, created_at: 0, updated_at: 0, } } +fn word_creator_definition() -> AssistantDefinitionRow { + AssistantDefinitionRow { + id: "def-word-creator".into(), + assistant_id: "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: Some("Drafts Word documents".into()), + description_i18n: "{}".into(), + avatar_type: "builtin_asset".into(), + avatar_value: None, + agent_id: "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, + } +} + fn setup_with_metadata_rows(rows: Vec) -> Arc { let agent_metadata_repo: Arc = Arc::new(StubAgentMetadataRepo::with_rows(rows)); setup_with_factory_and_metadata(success_factory(), agent_metadata_repo).0 @@ -1513,17 +1778,17 @@ fn two_agent_input() -> Vec { TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, conversation_id: None, }, TeamAgentInput { name: "Worker".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, conversation_id: None, }, ] @@ -1566,11 +1831,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] @@ -1597,7 +1862,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), @@ -1632,7 +1897,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), @@ -1642,12 +1907,55 @@ 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() { - 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 { + id: "def-team-lead".into(), + assistant_id: "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_id: "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( @@ -1657,9 +1965,9 @@ async fn tc_create_team_uses_custom_agent_id_icon_lookup() { agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "acp".into(), + backend: Some("claude".into()), model: "claude".into(), - custom_agent_id: Some("2d23ff1c".into()), + assistant_id: Some("assistant-lead".into()), conversation_id: None, }], workspace: None, @@ -1669,21 +1977,56 @@ async fn tc_create_team_uses_custom_agent_id_icon_lookup() { .unwrap(); assert_eq!( - resp.agents[0].icon.as_deref(), - Some("/api/assets/logos/ai-major/claude.svg") + resp.assistants[0].icon.as_deref(), + 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 { + id: "def-team-lead".into(), + assistant_id: "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_id: "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( @@ -1693,9 +2036,9 @@ 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(), - custom_agent_id: Some("2d23ff1c".into()), + assistant_id: Some("assistant-lead".into()), conversation_id: None, }], workspace: None, @@ -1705,14 +2048,184 @@ 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"); let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap(); - assert_eq!(extra["custom_agent_id"], serde_json::json!("2d23ff1c")); - assert_eq!(extra["preset_assistant_id"], serde_json::json!("2d23ff1c")); + assert_eq!(extra["assistant_id"], serde_json::json!("assistant-lead")); + assert!(extra.get("preset_assistant_id").is_none()); +} + +#[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 { + id: "def-team-lead".into(), + assistant_id: "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_id: "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 { + assistant_definition_id: "def-team-lead".into(), + enabled: true, + sort_order: 0, + agent_id_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(String::new()), + model: "gpt-5".into(), + assistant_id: Some("assistant-lead".into()), + conversation_id: None, + }], + workspace: None, + }, + ) + .await + .unwrap(); + + assert_eq!(created.assistants[0].backend, "codex"); + let extra = conv_repo + .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!( + extra.get("assistant_id").and_then(serde_json::Value::as_str), + Some("assistant-lead") + ); +} + +#[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 { + id: "def-team-lead".into(), + assistant_id: "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_id: "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 { + assistant_definition_id: "def-team-lead".into(), + enabled: true, + sort_order: 0, + agent_id_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.assistants[0].backend, "codex"); + let extra = conv_repo + .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")); } fn fake_preset_snapshot(rules: &str, skills: &[&str], mcp_server_ids: &[&str]) -> FakePresetAssistantSnapshot { @@ -1724,8 +2237,7 @@ fn fake_preset_snapshot(rules: &str, skills: &[&str], mcp_server_ids: &[&str]) - } fn assert_frozen_preset_extra(extra: &serde_json::Value) { - assert_eq!(extra["preset_assistant_id"], serde_json::json!("word-creator")); - assert_eq!(extra["custom_agent_id"], serde_json::json!("word-creator")); + assert_eq!(extra["assistant_id"], serde_json::json!("word-creator")); assert_eq!(extra["preset_context"], serde_json::json!("assistant rule body")); assert_eq!(extra["preset_rules"], serde_json::json!("assistant rule body")); assert_eq!(extra["skills"], serde_json::json!(["pdf", "cron"])); @@ -1734,8 +2246,15 @@ fn assert_frozen_preset_extra(extra: &serde_json::Value) { #[tokio::test] async fn team_preset_assistant_snapshot_is_frozen() { - let (svc, _team_repo, conversation_ports, conv_repo) = - setup_with_ports_team_repo_and_conversation_repo(success_factory(), Arc::new(StubAgentMetadataRepo::empty())); + let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { + row: word_creator_definition(), + }); + let (svc, _team_repo, conversation_ports, conv_repo) = setup_with_ports_metadata_assistants_and_conversation_repo( + success_factory(), + Arc::new(StubAgentMetadataRepo::empty()), + definition_repo, + Arc::new(EmptyAssistantOverlayRepo), + ); conversation_ports.upsert_preset_snapshot( "word-creator", fake_preset_snapshot("assistant rule body", &["pdf", "cron"], &["mcp-docs"]), @@ -1749,9 +2268,9 @@ async fn team_preset_assistant_snapshot_is_frozen() { agents: vec![TeamAgentInput { name: "Lead".into(), role: "lead".into(), - backend: "claude".into(), + backend: Some("claude".into()), model: "claude-sonnet-4".into(), - custom_agent_id: Some("word-creator".into()), + assistant_id: Some("word-creator".into()), conversation_id: None, }], workspace: None, @@ -1760,7 +2279,7 @@ async fn team_preset_assistant_snapshot_is_frozen() { .await .expect("create team"); - let extra = conv_repo.get_extra(&resp.agents[0].conversation_id).unwrap(); + let extra = conv_repo.get_extra(&resp.assistants[0].conversation_id).unwrap(); assert_frozen_preset_extra(&extra); conversation_ports.upsert_preset_snapshot( @@ -1768,14 +2287,21 @@ async fn team_preset_assistant_snapshot_is_frozen() { fake_preset_snapshot("changed rule body", &["changed"], &["changed-mcp"]), ); - let after_live_change = conv_repo.get_extra(&resp.agents[0].conversation_id).unwrap(); + let after_live_change = conv_repo.get_extra(&resp.assistants[0].conversation_id).unwrap(); assert_frozen_preset_extra(&after_live_change); } #[tokio::test] async fn spawned_preset_assistant_snapshot_is_frozen() { - let (svc, _team_repo, conversation_ports, conv_repo) = - setup_with_ports_team_repo_and_conversation_repo(success_factory(), Arc::new(StubAgentMetadataRepo::empty())); + let definition_repo: Arc = Arc::new(SingleAssistantDefinitionRepo { + row: word_creator_definition(), + }); + let (svc, _team_repo, conversation_ports, conv_repo) = setup_with_ports_metadata_assistants_and_conversation_repo( + success_factory(), + Arc::new(StubAgentMetadataRepo::empty()), + definition_repo, + Arc::new(EmptyAssistantOverlayRepo), + ); conversation_ports.upsert_preset_snapshot( "word-creator", fake_preset_snapshot("assistant rule body", &["pdf", "cron"], &["mcp-docs"]), @@ -1792,7 +2318,7 @@ async fn spawned_preset_assistant_snapshot_is_frozen() { ) .await .expect("create team"); - let lead_slot_id = created.lead_agent_id.clone().expect("lead slot"); + let lead_slot_id = created.leader_assistant_id.clone().expect("lead slot"); svc.ensure_session("user1", &created.id).await.expect("ensure session"); svc.send_message("user1", &created.id, "start active run", None) .await @@ -1804,8 +2330,7 @@ async fn spawned_preset_assistant_snapshot_is_frozen() { &lead_slot_id, SpawnAgentRequest { name: "Writer".into(), - agent_type: Some("claude".into()), - custom_agent_id: Some("word-creator".into()), + assistant_id: Some("word-creator".into()), model: Some("claude-sonnet-4".into()), }, ) @@ -1840,9 +2365,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, conversation_id: None, }], workspace: None, @@ -1858,9 +2383,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, }, ) .await @@ -1869,6 +2394,195 @@ 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 { + id: "def-team-worker".into(), + assistant_id: "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_id: "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 { + assistant_definition_id: "def-team-worker".into(), + enabled: true, + sort_order: 0, + agent_id_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: 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(String::new()), + model: "gpt-5".into(), + assistant_id: Some("assistant-worker".into()), + }, + ) + .await + .unwrap(); + + assert_eq!(added.backend, "codex"); + 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 { + id: "def-team-worker".into(), + assistant_id: "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_id: "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 { + assistant_definition_id: "def-team-worker".into(), + enabled: true, + sort_order: 0, + agent_id_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(); @@ -1880,9 +2594,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, conversation_id: None, }], workspace: None, @@ -1891,8 +2605,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] @@ -1907,17 +2621,17 @@ async fn tc4_first_agent_is_lead() { TeamAgentInput { name: "A".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, conversation_id: None, }, TeamAgentInput { name: "B".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, conversation_id: None, }, ], @@ -1927,8 +2641,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] @@ -1962,10 +2676,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 --------------------------------------------------------------- @@ -2046,9 +2760,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, conversation_id: None, }], workspace: None, @@ -2056,7 +2770,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, @@ -2071,7 +2785,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); } @@ -2095,7 +2809,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] @@ -2218,9 +2932,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, conversation_id: None, }], workspace: None, @@ -2236,9 +2950,9 @@ async fn aa1_add_agent_to_team() { AddAgentRequest { name: "Worker".into(), role: "teammate".into(), - backend: "acp".into(), + backend: Some("acp".into()), model: "claude".into(), - custom_agent_id: None, + assistant_id: None, }, ) .await @@ -2249,7 +2963,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] @@ -2268,9 +2982,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, conversation_id: None, }], workspace: Some(workspace.clone()), @@ -2286,9 +3000,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, }, ) .await @@ -2314,9 +3028,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, conversation_id: None, }], workspace: None, @@ -2324,7 +3038,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(); @@ -2338,9 +3052,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, }, ) .await @@ -2368,9 +3082,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, conversation_id: None, }], workspace: None, @@ -2382,7 +3096,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(); @@ -2394,9 +3108,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, }, ) .await @@ -2429,9 +3143,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, conversation_id: None, }], workspace: None, @@ -2451,9 +3165,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, }, ) .await @@ -2479,9 +3193,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, conversation_id: None, }], workspace: None, @@ -2493,7 +3207,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(); @@ -2508,9 +3222,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, }, ) .await @@ -2545,7 +3259,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!( @@ -2571,9 +3285,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, }, ) .await @@ -2606,9 +3320,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, }, ) .await; @@ -2630,12 +3344,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] @@ -2672,13 +3386,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"); } @@ -2724,8 +3438,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 { + id: "def-spawn-worker".into(), + assistant_id: "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_id: "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 { + assistant_definition_id: "def-spawn-worker".into(), + enabled: true, + sort_order: 0, + agent_id_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", @@ -2742,44 +3509,84 @@ 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"); let req = SpawnAgentRequest { name: "Helper".into(), - agent_type: Some("claude".into()), - custom_agent_id: None, + 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) .await .expect("team should still be readable"); assert_eq!( - after.agents.len(), - created.agents.len(), - "failed spawn must not persist a partial teammate" + after.assistants.len(), + 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_requires_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"); + + let err = svc + .send_agent_message_from_agent(&created.id, &lead_slot_id, &worker_slot_id, "Do this") + .await + .expect_err("leader direct message should require active Team Run"); + assert!(err.to_string().contains("no active team run")); +} + #[tokio::test] async fn spawn_agent_in_session_aborts_lease_when_persistence_fails() { - let (svc, team_repo, _, _) = setup_with_factory_metadata_team_repo_and_conversation_repo( + let (svc, team_repo, _, _) = setup_with_factory_metadata_assistants_and_conversation_repo( success_factory(), Arc::new(StubAgentMetadataRepo::empty()), + Arc::new(SingleAssistantDefinitionRepo { + row: word_creator_definition(), + }), + Arc::new(EmptyAssistantOverlayRepo), ); let created = svc .create_team( @@ -2798,11 +3605,10 @@ async fn spawn_agent_in_session_aborts_lease_when_persistence_fails() { .expect("active run"); team_repo.fail_agent_updates(); - let lead_slot_id = created.lead_agent_id.clone().unwrap(); + let lead_slot_id = created.leader_assistant_id.clone().unwrap(); let req = SpawnAgentRequest { name: "Helper".into(), - agent_type: Some("claude".into()), - custom_agent_id: None, + assistant_id: Some("word-creator".into()), model: Some("claude-sonnet-4".into()), }; @@ -2814,16 +3620,20 @@ async fn spawn_agent_in_session_aborts_lease_when_persistence_fails() { let after = svc.get_team("user1", &created.id).await.unwrap(); assert!( - after.agents.iter().all(|agent| agent.name != "Helper"), + after.assistants.iter().all(|agent| agent.name != "Helper"), "failed spawn must not persist helper after aborted spawn lease" ); } #[tokio::test] async fn spawn_agent_in_session_compensates_when_welcome_mailbox_write_fails() { - let (svc, team_repo, _, _) = setup_with_factory_metadata_team_repo_and_conversation_repo( + let (svc, team_repo, _, _) = setup_with_factory_metadata_assistants_and_conversation_repo( success_factory(), Arc::new(StubAgentMetadataRepo::empty()), + Arc::new(SingleAssistantDefinitionRepo { + row: word_creator_definition(), + }), + Arc::new(EmptyAssistantOverlayRepo), ); let created = svc .create_team( @@ -2842,11 +3652,10 @@ async fn spawn_agent_in_session_compensates_when_welcome_mailbox_write_fails() { .expect("active run"); team_repo.fail_message_writes(); - let lead_slot_id = created.lead_agent_id.clone().unwrap(); + let lead_slot_id = created.leader_assistant_id.clone().unwrap(); let req = SpawnAgentRequest { name: "Helper".into(), - agent_type: Some("claude".into()), - custom_agent_id: None, + assistant_id: Some("word-creator".into()), model: Some("claude-sonnet-4".into()), }; @@ -2858,7 +3667,7 @@ async fn spawn_agent_in_session_compensates_when_welcome_mailbox_write_fails() { let after = svc.get_team("user1", &created.id).await.unwrap(); assert!( - after.agents.iter().all(|agent| agent.name != "Helper"), + after.assistants.iter().all(|agent| agent.name != "Helper"), "compensation must remove persisted helper after welcome write failure" ); } @@ -3063,7 +3872,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(); @@ -3083,7 +3892,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) @@ -3207,7 +4016,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); @@ -3262,9 +4071,9 @@ async fn d9_create_team_from_running_solo_leader_rebuilds_leader_after_turn_fini agents: vec![TeamAgentInput { name: "Leader".into(), role: "lead".into(), - backend: "claude".into(), + backend: Some("claude".into()), model: "opus".into(), - custom_agent_id: None, + assistant_id: None, conversation_id: Some(lead_conversation_id.to_owned()), }], workspace: None, @@ -3317,7 +4126,7 @@ async fn d9_create_team_from_running_solo_leader_rebuilds_leader_after_turn_fini calls.build, vec![lead_conversation_id.to_owned(), lead_conversation_id.to_owned()] ); - assert_eq!(created.agents.len(), 1); + assert_eq!(created.assistants.len(), 1); } #[tokio::test] @@ -3457,9 +4266,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, conversation_id: None, }], workspace: None, @@ -3478,9 +4287,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, }, ) .await @@ -3496,9 +4305,9 @@ 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(), - custom_agent_id: None, + assistant_id: None, }, ) .await @@ -3510,11 +4319,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")); @@ -3550,10 +4359,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)); }