diff --git a/Cargo.lock b/Cargo.lock index 5090bcdd..153abf6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4255,6 +4255,8 @@ version = "0.1.0" dependencies = [ "anyhow", "assert_cmd", + "async-trait", + "chrono", "clap", "colored 2.2.0", "comfy-table", @@ -4263,7 +4265,9 @@ dependencies = [ "dialoguer", "dirs-next", "indicatif", + "mofa-foundation", "mofa-kernel", + "mofa-runtime", "mofa-sdk", "predicates", "ratatui", diff --git a/crates/mofa-cli/Cargo.toml b/crates/mofa-cli/Cargo.toml index ed7ec1e8..6770b25c 100644 --- a/crates/mofa-cli/Cargo.toml +++ b/crates/mofa-cli/Cargo.toml @@ -21,9 +21,13 @@ path = "src/main.rs" # Core mofa-sdk = { path = "../mofa-sdk", version = "0.1" } mofa-kernel = { path = "../mofa-kernel", version = "0.1", features = ["config"] } +mofa-runtime = { path = "../mofa-runtime", version = "0.1" } +mofa-foundation = { path = "../mofa-foundation", version = "0.1" } config.workspace = true tokio = { workspace = true } anyhow = { workspace = true } +async-trait = { workspace = true } +chrono = { workspace = true } # CLI clap = { version = "4", features = ["derive", "env"] } diff --git a/crates/mofa-cli/src/cli.rs b/crates/mofa-cli/src/cli.rs index 23835d06..3834bd23 100644 --- a/crates/mofa-cli/src/cli.rs +++ b/crates/mofa-cli/src/cli.rs @@ -219,6 +219,10 @@ pub enum AgentCommands { #[arg(short, long)] config: Option, + /// Agent factory type (use `mofa agent status` to inspect available factories) + #[arg(long = "type")] + factory_type: Option, + /// Run as daemon #[arg(long)] daemon: bool, diff --git a/crates/mofa-cli/src/commands/agent/list.rs b/crates/mofa-cli/src/commands/agent/list.rs index de6565fb..8f216c56 100644 --- a/crates/mofa-cli/src/commands/agent/list.rs +++ b/crates/mofa-cli/src/commands/agent/list.rs @@ -1,56 +1,76 @@ //! `mofa agent list` command implementation +use crate::context::CliContext; use crate::output::Table; use colored::Colorize; use serde::Serialize; +use std::collections::BTreeMap; /// Execute the `mofa agent list` command -pub fn run(running_only: bool, show_all: bool) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, running_only: bool, _show_all: bool) -> anyhow::Result<()> { println!("{} Listing agents", "→".green()); + println!(); + + let agents_metadata = ctx.agent_registry.list().await; + let persisted_agents = ctx + .agent_store + .list() + .map_err(|e| anyhow::anyhow!("Failed to list persisted agents: {}", e))?; - if running_only { - println!(" Showing running agents only"); - } else if show_all { - println!(" Showing all agents"); + let mut merged: BTreeMap = BTreeMap::new(); + for m in &agents_metadata { + let status = format!("{:?}", m.state); + let is_running = is_running_state(&status); + merged.insert( + m.id.clone(), + AgentInfo { + id: m.id.clone(), + name: m.name.clone(), + status, + is_running, + description: m.description.clone(), + }, + ); } - println!(); + for (_, entry) in persisted_agents { + merged.entry(entry.id.clone()).or_insert_with(|| { + let status = if is_running_state(&entry.state) { + format!("{} (persisted)", entry.state) + } else { + entry.state + }; + AgentInfo { + id: entry.id, + name: entry.name, + status, + is_running: false, + description: entry.description, + } + }); + } - // TODO: Implement actual agent listing from state store - // For now, show example output + if merged.is_empty() { + println!(" No agents registered."); + println!(); + println!( + " Use {} to start an agent.", + "mofa agent start ".cyan() + ); + return Ok(()); + } - let agents = vec![ - AgentInfo { - id: "agent-001".to_string(), - name: "MyAgent".to_string(), - status: "running".to_string(), - uptime: Some("5m 32s".to_string()), - provider: Some("openai".to_string()), - model: Some("gpt-4o".to_string()), - }, - AgentInfo { - id: "agent-002".to_string(), - name: "TestAgent".to_string(), - status: "stopped".to_string(), - uptime: None, - provider: None, - model: None, - }, - ]; + let agents: Vec = merged.into_values().collect(); // Filter based on flags let filtered: Vec<_> = if running_only { - agents - .iter() - .filter(|a| a.status == "running") - .cloned() - .collect() + agents.into_iter().filter(|a| a.is_running).collect() } else { agents }; if filtered.is_empty() { - println!(" No agents found."); + println!(" No agents found matching criteria."); return Ok(()); } @@ -69,10 +89,12 @@ struct AgentInfo { id: String, name: String, status: String, + #[serde(skip_serializing)] + is_running: bool, #[serde(skip_serializing_if = "Option::is_none")] - uptime: Option, - #[serde(skip_serializing_if = "Option::is_none")] - provider: Option, - #[serde(skip_serializing_if = "Option::is_none")] - model: Option, + description: Option, +} + +fn is_running_state(status: &str) -> bool { + status == "Running" || status == "Ready" } diff --git a/crates/mofa-cli/src/commands/agent/restart.rs b/crates/mofa-cli/src/commands/agent/restart.rs index abb6ca02..7debfd26 100644 --- a/crates/mofa-cli/src/commands/agent/restart.rs +++ b/crates/mofa-cli/src/commands/agent/restart.rs @@ -1,17 +1,54 @@ //! `mofa agent restart` command implementation +use crate::context::CliContext; use colored::Colorize; /// Execute the `mofa agent restart` command -pub fn run(agent_id: &str, _config: Option<&std::path::Path>) -> anyhow::Result<()> { +pub async fn run( + ctx: &CliContext, + agent_id: &str, + config: Option<&std::path::Path>, +) -> anyhow::Result<()> { println!("{} Restarting agent: {}", "→".green(), agent_id.cyan()); - // TODO: Implement actual agent restart logic - // This would involve: - // 1. Stopping the agent - // 2. Starting it again with the same config + // Stop the agent if it's running + if ctx.agent_registry.contains(agent_id).await { + super::stop::run(ctx, agent_id).await?; + } else { + println!(" Agent was not running"); + } + + // Start it again + super::start::run(ctx, agent_id, config, None, false).await?; println!("{} Agent '{}' restarted", "✓".green(), agent_id); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::agent::{list, start, stop}; + use crate::context::CliContext; + use tempfile::TempDir; + + #[tokio::test] + async fn test_restart_chain_start_stop_restart_list() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + start::run(&ctx, "chain-agent", None, None, false) + .await + .unwrap(); + stop::run(&ctx, "chain-agent").await.unwrap(); + run(&ctx, "chain-agent", None).await.unwrap(); + + assert!(ctx.agent_registry.contains("chain-agent").await); + let persisted = ctx.agent_store.get("chain-agent").unwrap().unwrap(); + assert_eq!(persisted.state, "Running"); + + list::run(&ctx, false, false).await.unwrap(); + list::run(&ctx, true, false).await.unwrap(); + } +} diff --git a/crates/mofa-cli/src/commands/agent/start.rs b/crates/mofa-cli/src/commands/agent/start.rs index 8b450ba3..6a300c94 100644 --- a/crates/mofa-cli/src/commands/agent/start.rs +++ b/crates/mofa-cli/src/commands/agent/start.rs @@ -1,22 +1,186 @@ //! `mofa agent start` command implementation +use crate::config::loader::ConfigLoader; +use crate::context::{AgentConfigEntry, CliContext}; use colored::Colorize; /// Execute the `mofa agent start` command -pub fn run(agent_id: &str, _config: Option<&std::path::Path>, daemon: bool) -> anyhow::Result<()> { +pub async fn run( + ctx: &CliContext, + agent_id: &str, + config_path: Option<&std::path::Path>, + factory_type: Option<&str>, + daemon: bool, +) -> anyhow::Result<()> { println!("{} Starting agent: {}", "→".green(), agent_id.cyan()); if daemon { println!(" Mode: {}", "daemon".yellow()); } - // TODO: Implement actual agent starting logic - // This would involve: - // 1. Loading agent configuration - // 2. Starting the agent process - // 3. Storing agent state/PID + // Check if agent is already registered + if ctx.agent_registry.contains(agent_id).await { + anyhow::bail!("Agent '{}' is already registered", agent_id); + } + + // Load agent configuration + let agent_config = if let Some(path) = config_path { + println!(" Config: {}", path.display().to_string().cyan()); + let loader = ConfigLoader::new(); + let cli_config = loader.load(path)?; + println!(" Agent: {}", cli_config.agent.name.white()); + + // Convert CLI AgentConfig to kernel AgentConfig + mofa_kernel::agent::config::AgentConfig::new(agent_id, &cli_config.agent.name) + } else { + // Try to auto-discover configuration + let loader = ConfigLoader::new(); + match loader.find_config() { + Some(found_path) => { + println!( + " Config: {} (auto-discovered)", + found_path.display().to_string().cyan() + ); + let cli_config = loader.load(&found_path)?; + println!(" Agent: {}", cli_config.agent.name.white()); + mofa_kernel::agent::config::AgentConfig::new(agent_id, &cli_config.agent.name) + } + None => { + println!(" {} No config file found, using defaults", "!".yellow()); + mofa_kernel::agent::config::AgentConfig::new(agent_id, agent_id) + } + } + }; + + // Check if a matching factory type is available + let mut factory_types = ctx.agent_registry.list_factory_types().await; + if factory_types.is_empty() { + anyhow::bail!( + "No agent factories registered. Cannot start agent '{}'", + agent_id + ); + } + factory_types.sort(); + + let selected_factory = select_factory_type(&factory_types, factory_type)?; + println!(" Factory: {}", selected_factory.cyan()); + if factory_type.is_none() && factory_types.len() > 1 { + println!( + " {} Multiple factories available, defaulted to '{}'. Use --type to choose.", + "!".yellow(), + selected_factory + ); + } + + // Try to create via factory + ctx.agent_registry + .create_and_register(&selected_factory, agent_config.clone()) + .await + .map_err(|e| anyhow::anyhow!("Failed to start agent '{}': {}", agent_id, e))?; + + let entry = AgentConfigEntry { + id: agent_id.to_string(), + name: agent_config.name.clone(), + state: "Running".to_string(), + description: agent_config.description.clone(), + }; + if let Err(e) = ctx.agent_store.save(agent_id, &entry) { + let rollback_result = ctx.agent_registry.unregister(agent_id).await; + match rollback_result { + Ok(_) => { + anyhow::bail!( + "Failed to persist agent '{}': {}. Rolled back in-memory registration.", + agent_id, + e + ); + } + Err(rollback_err) => { + anyhow::bail!( + "Failed to persist agent '{}': {}. Rollback failed: {}", + agent_id, + e, + rollback_err + ); + } + } + } println!("{} Agent '{}' started", "✓".green(), agent_id); Ok(()) } + +fn select_factory_type( + factory_types: &[String], + requested_factory: Option<&str>, +) -> anyhow::Result { + if let Some(requested) = requested_factory { + if factory_types.iter().any(|factory| factory == requested) { + return Ok(requested.to_string()); + } + anyhow::bail!( + "Factory '{}' is not registered. Available factories: {}", + requested, + factory_types.join(", ") + ); + } + + Ok(factory_types + .first() + .expect("factory_types must be non-empty") + .clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::CliContext; + use tempfile::TempDir; + + #[tokio::test] + async fn test_start_succeeds_with_default_factory() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + let result = run(&ctx, "test-agent", None, None, false).await; + assert!(result.is_ok()); + assert!(ctx.agent_registry.contains("test-agent").await); + } + + #[tokio::test] + async fn test_start_returns_err_for_duplicate_agent() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + run(&ctx, "dup-agent", None, None, false).await.unwrap(); + let result = run(&ctx, "dup-agent", None, None, false).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_start_returns_err_for_unknown_factory_type() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + let result = run(&ctx, "typed-agent", None, Some("missing-factory"), false).await; + assert!(result.is_err()); + assert!(!ctx.agent_registry.contains("typed-agent").await); + } + + #[tokio::test] + async fn test_start_succeeds_with_explicit_factory_type() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + let factory = ctx + .agent_registry + .list_factory_types() + .await + .into_iter() + .next() + .unwrap(); + + let result = run(&ctx, "typed-agent-ok", None, Some(&factory), false).await; + assert!(result.is_ok()); + assert!(ctx.agent_registry.contains("typed-agent-ok").await); + } +} diff --git a/crates/mofa-cli/src/commands/agent/status.rs b/crates/mofa-cli/src/commands/agent/status.rs index 40330ed4..9291f1a5 100644 --- a/crates/mofa-cli/src/commands/agent/status.rs +++ b/crates/mofa-cli/src/commands/agent/status.rs @@ -1,21 +1,66 @@ //! `mofa agent status` command implementation +use crate::context::CliContext; use colored::Colorize; /// Execute the `mofa agent status` command -pub fn run(agent_id: Option<&str>) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, agent_id: Option<&str>) -> anyhow::Result<()> { if let Some(id) = agent_id { // Show status for a specific agent println!("{} Agent status: {}", "→".green(), id.cyan()); println!(); - println!(" ID: {}", id); - println!(" Status: {}", "Running".green()); - println!(" Uptime: {}", "5m 32s".white()); + + match ctx.agent_registry.get_metadata(id).await { + Some(metadata) => { + println!(" ID: {}", metadata.id.cyan()); + println!(" Name: {}", metadata.name.white()); + println!( + " State: {}", + format!("{:?}", metadata.state).green() + ); + if let Some(desc) = &metadata.description { + println!(" Description: {}", desc.white()); + } + if let Some(ver) = &metadata.version { + println!(" Version: {}", ver.white()); + } + let caps = &metadata.capabilities; + if !caps.tags.is_empty() { + let tags: Vec<_> = caps.tags.iter().cloned().collect(); + println!(" Tags: {}", tags.join(", ").white()); + } + } + None => { + println!(" Agent '{}' not found in registry", id); + println!(); + println!( + " Use {} to see available agents.", + "mofa agent list".cyan() + ); + } + } } else { // Show summary of all agents - println!("{} Agent Status", "→".green()); + println!("{} Agent Status Summary", "→".green()); println!(); - println!(" No agents currently running."); + + let stats = ctx.agent_registry.stats().await; + + if stats.total_agents == 0 { + println!(" No agents currently registered."); + return Ok(()); + } + + println!(" Total agents: {}", stats.total_agents); + if !stats.by_state.is_empty() { + println!(" By state:"); + for (state, count) in &stats.by_state { + println!(" {}: {}", state, count); + } + } + if stats.factory_count > 0 { + println!(" Factories: {}", stats.factory_count); + } } Ok(()) diff --git a/crates/mofa-cli/src/commands/agent/stop.rs b/crates/mofa-cli/src/commands/agent/stop.rs index fb1b295d..2e1e1f26 100644 --- a/crates/mofa-cli/src/commands/agent/stop.rs +++ b/crates/mofa-cli/src/commands/agent/stop.rs @@ -1,18 +1,104 @@ //! `mofa agent stop` command implementation +use crate::context::CliContext; use colored::Colorize; /// Execute the `mofa agent stop` command -pub fn run(agent_id: &str) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, agent_id: &str) -> anyhow::Result<()> { println!("{} Stopping agent: {}", "→".green(), agent_id.cyan()); - // TODO: Implement actual agent stopping logic - // This would involve: - // 1. Looking up the agent's PID/state - // 2. Sending a shutdown signal - // 3. Waiting for graceful shutdown + // Check if agent exists + if !ctx.agent_registry.contains(agent_id).await { + anyhow::bail!("Agent '{}' not found in registry", agent_id); + } - println!("{} Agent '{}' stopped", "✓".green(), agent_id); + // Attempt graceful shutdown via the agent instance + if let Some(agent) = ctx.agent_registry.get(agent_id).await { + let mut agent_guard = agent.write().await; + if let Err(e) = agent_guard.shutdown().await { + println!(" {} Graceful shutdown failed: {}", "!".yellow(), e); + } + } + + let previous_entry = ctx + .agent_store + .get(agent_id) + .map_err(|e| anyhow::anyhow!("Failed to load persisted agent '{}': {}", agent_id, e))?; + + let persisted_updated = if let Some(mut entry) = previous_entry.clone() { + entry.state = "Stopped".to_string(); + ctx.agent_store + .save(agent_id, &entry) + .map_err(|e| anyhow::anyhow!("Failed to update agent '{}': {}", agent_id, e))?; + true + } else { + false + }; + + // Unregister from the registry after persistence update so failures do not leave stale state. + let removed = ctx + .agent_registry + .unregister(agent_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to unregister agent: {}", e))?; + + if !removed && persisted_updated { + if let Some(previous) = previous_entry { + ctx.agent_store.save(agent_id, &previous).map_err(|e| { + anyhow::anyhow!( + "Agent '{}' remained registered and failed to restore persisted state: {}", + agent_id, + e + ) + })?; + } + } + + if removed { + println!( + "{} Agent '{}' stopped and unregistered", + "✓".green(), + agent_id + ); + } else { + println!( + "{} Agent '{}' was not in the registry", + "!".yellow(), + agent_id + ); + } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::agent::start; + use crate::context::CliContext; + use tempfile::TempDir; + + #[tokio::test] + async fn test_stop_updates_state_and_unregisters_agent() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + start::run(&ctx, "stop-agent", None, None, false) + .await + .unwrap(); + run(&ctx, "stop-agent").await.unwrap(); + + assert!(!ctx.agent_registry.contains("stop-agent").await); + let persisted = ctx.agent_store.get("stop-agent").unwrap().unwrap(); + assert_eq!(persisted.state, "Stopped"); + } + + #[tokio::test] + async fn test_stop_returns_error_for_missing_agent() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + let result = run(&ctx, "missing-agent").await; + assert!(result.is_err()); + } +} diff --git a/crates/mofa-cli/src/commands/plugin/info.rs b/crates/mofa-cli/src/commands/plugin/info.rs index a08335b1..46231803 100644 --- a/crates/mofa-cli/src/commands/plugin/info.rs +++ b/crates/mofa-cli/src/commands/plugin/info.rs @@ -1,26 +1,47 @@ //! `mofa plugin info` command implementation +use crate::context::CliContext; use colored::Colorize; +use mofa_kernel::agent::plugins::PluginRegistry; /// Execute the `mofa plugin info` command -pub fn run(name: &str) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, name: &str) -> anyhow::Result<()> { println!("{} Plugin information: {}", "→".green(), name.cyan()); println!(); - // TODO: Implement actual plugin info lookup - // For now, show example output + match ctx.plugin_registry.get(name) { + Some(plugin) => { + let metadata = plugin.metadata(); + println!(" Name: {}", plugin.name().cyan()); + println!(" Description: {}", plugin.description().white()); + println!(" Version: {}", metadata.version.white()); + println!( + " Stages: {}", + metadata + .stages + .iter() + .map(|s| format!("{:?}", s)) + .collect::>() + .join(", ") + .white() + ); + if !metadata.custom.is_empty() { + println!(" Custom attrs:"); + for (key, value) in &metadata.custom { + println!(" {}: {}", key, value); + } + } + } + None => { + println!(" Plugin '{}' not found in registry", name); + println!(); + println!( + " Use {} to see available plugins.", + "mofa plugin list".cyan() + ); + } + } - println!(" Name: {}", name.cyan()); - println!(" Version: {}", "0.1.0".white()); - println!(" Description: {}", "A helpful plugin".white()); - println!(" Author: {}", "MoFA Team".white()); - println!( - " Repository: {}", - "https://github.com/mofa-org/...".blue() - ); - println!(" License: {}", "MIT".white()); - println!(" Installed: {}", "Yes".green()); println!(); - Ok(()) } diff --git a/crates/mofa-cli/src/commands/plugin/list.rs b/crates/mofa-cli/src/commands/plugin/list.rs index a4606a37..1d00b0ef 100644 --- a/crates/mofa-cli/src/commands/plugin/list.rs +++ b/crates/mofa-cli/src/commands/plugin/list.rs @@ -1,62 +1,44 @@ //! `mofa plugin list` command implementation +use crate::context::CliContext; use crate::output::Table; use colored::Colorize; +use mofa_kernel::agent::plugins::PluginRegistry; use serde::Serialize; /// Execute the `mofa plugin list` command -pub fn run(installed_only: bool, available: bool) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, _installed_only: bool, _available: bool) -> anyhow::Result<()> { println!("{} Listing plugins", "→".green()); - - if installed_only { - println!(" Showing installed plugins"); - } else if available { - println!(" Showing available plugins"); - } - println!(); - // TODO: Implement actual plugin discovery from plugin registry - - let plugins = vec![ - PluginInfo { - name: "http-server".to_string(), - version: "0.1.0".to_string(), - description: "HTTP server plugin for exposing agents via REST API".to_string(), - installed: true, - }, - PluginInfo { - name: "postgres-persistence".to_string(), - version: "0.1.0".to_string(), - description: "PostgreSQL persistence plugin for session storage".to_string(), - installed: true, - }, - PluginInfo { - name: "web-scraper".to_string(), - version: "0.2.0".to_string(), - description: "Web scraping tool for content extraction".to_string(), - installed: false, - }, - PluginInfo { - name: "code-interpreter".to_string(), - version: "0.1.0".to_string(), - description: "Sandboxed code execution environment".to_string(), - installed: false, - }, - ]; - - let filtered: Vec<_> = if installed_only { - plugins.iter().filter(|p| p.installed).cloned().collect() - } else { - plugins - }; + let plugins = ctx.plugin_registry.list(); - if filtered.is_empty() { - println!(" No plugins found."); + if plugins.is_empty() { + println!(" No plugins registered."); + println!(); + println!(" Plugins can be registered programmatically via the SDK."); return Ok(()); } - let json = serde_json::to_value(&filtered)?; + let infos: Vec = plugins + .iter() + .map(|p| { + let metadata = p.metadata(); + PluginInfo { + name: p.name().to_string(), + version: metadata.version.clone(), + description: p.description().to_string(), + stages: metadata + .stages + .iter() + .map(|s| format!("{:?}", s)) + .collect::>() + .join(", "), + } + }) + .collect(); + + let json = serde_json::to_value(&infos)?; if let Some(arr) = json.as_array() { let table = Table::from_json_array(arr); println!("{}", table); @@ -70,5 +52,5 @@ struct PluginInfo { name: String, version: String, description: String, - installed: bool, + stages: String, } diff --git a/crates/mofa-cli/src/commands/plugin/uninstall.rs b/crates/mofa-cli/src/commands/plugin/uninstall.rs index f5eb7f98..f93c204a 100644 --- a/crates/mofa-cli/src/commands/plugin/uninstall.rs +++ b/crates/mofa-cli/src/commands/plugin/uninstall.rs @@ -1,19 +1,90 @@ //! `mofa plugin uninstall` command implementation +use crate::context::CliContext; use colored::Colorize; +use dialoguer::Confirm; +use mofa_kernel::agent::plugins::PluginRegistry; /// Execute the `mofa plugin uninstall` command -pub fn run(name: &str, _force: bool) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, name: &str, force: bool) -> anyhow::Result<()> { + // Check if plugin exists + if !ctx.plugin_registry.contains(name) { + anyhow::bail!("Plugin '{}' not found in registry", name); + } + + if !force { + let confirmed = Confirm::new() + .with_prompt(format!("Uninstall plugin '{}'?", name)) + .default(false) + .interact()?; + + if !confirmed { + println!("{} Cancelled", "→".yellow()); + return Ok(()); + } + } + println!("{} Uninstalling plugin: {}", "→".green(), name.cyan()); - // TODO: Implement actual plugin uninstallation - // This would involve: - // 1. Checking if plugin is installed - // 2. Confirming uninstallation (unless --force) - // 3. Removing plugin files - // 4. Updating plugin registry + let previous_spec = ctx + .plugin_store + .get(name) + .map_err(|e| anyhow::anyhow!("Failed to load plugin spec '{}': {}", name, e))?; + + let persisted_updated = if let Some(mut spec) = previous_spec.clone() { + spec.enabled = false; + ctx.plugin_store + .save(name, &spec) + .map_err(|e| anyhow::anyhow!("Failed to persist plugin '{}': {}", name, e))?; + true + } else { + false + }; + + let removed = ctx + .plugin_registry + .unregister(name) + .map_err(|e| anyhow::anyhow!("Failed to unregister plugin: {}", e))?; + + if !removed && persisted_updated { + if let Some(previous) = previous_spec { + ctx.plugin_store.save(name, &previous).map_err(|e| { + anyhow::anyhow!( + "Plugin '{}' remained registered and failed to restore persisted state: {}", + name, + e + ) + })?; + } + } - println!("{} Plugin '{}' uninstalled", "✓".green(), name); + if removed { + println!("{} Plugin '{}' uninstalled", "✓".green(), name); + } else { + println!("{} Plugin '{}' was not in the registry", "!".yellow(), name); + } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::CliContext; + use tempfile::TempDir; + + #[tokio::test] + async fn test_uninstall_persists_disabled_plugin_spec() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + run(&ctx, "http-plugin", true).await.unwrap(); + + let spec = ctx.plugin_store.get("http-plugin").unwrap().unwrap(); + assert!(!spec.enabled); + + drop(ctx); + let ctx2 = CliContext::with_temp_dir(temp.path()).await.unwrap(); + assert!(!ctx2.plugin_registry.contains("http-plugin")); + } +} diff --git a/crates/mofa-cli/src/commands/session/delete.rs b/crates/mofa-cli/src/commands/session/delete.rs index b1b1dc0a..783d4d86 100644 --- a/crates/mofa-cli/src/commands/session/delete.rs +++ b/crates/mofa-cli/src/commands/session/delete.rs @@ -1,23 +1,39 @@ //! `mofa session delete` command implementation +use crate::context::CliContext; use colored::Colorize; +use dialoguer::Confirm; /// Execute the `mofa session delete` command -pub fn run(session_id: &str, force: bool) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, session_id: &str, force: bool) -> anyhow::Result<()> { if !force { - println!("{} Delete session: {}?", "→".yellow(), session_id.cyan()); - println!(" This action cannot be undone."); - println!(); - println!(" Use --force to skip confirmation."); - // TODO: Add actual confirmation prompt - return Ok(()); + let confirmed = Confirm::new() + .with_prompt(format!( + "Delete session '{}'? This cannot be undone", + session_id + )) + .default(false) + .interact()?; + + if !confirmed { + println!("{} Cancelled", "→".yellow()); + return Ok(()); + } } println!("{} Deleting session: {}", "→".green(), session_id.cyan()); - // TODO: Implement actual session deletion from persistence layer + let deleted = ctx + .session_manager + .delete(session_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to delete session: {}", e))?; - println!("{} Session '{}' deleted", "✓".green(), session_id); + if deleted { + println!("{} Session '{}' deleted", "✓".green(), session_id); + } else { + println!("{} Session '{}' not found", "!".yellow(), session_id); + } Ok(()) } diff --git a/crates/mofa-cli/src/commands/session/export.rs b/crates/mofa-cli/src/commands/session/export.rs index 4fa8bc97..af277371 100644 --- a/crates/mofa-cli/src/commands/session/export.rs +++ b/crates/mofa-cli/src/commands/session/export.rs @@ -1,37 +1,45 @@ //! `mofa session export` command implementation +use crate::context::CliContext; use colored::Colorize; use std::path::PathBuf; /// Execute the `mofa session export` command -pub fn run(session_id: &str, output: PathBuf, format: &str) -> anyhow::Result<()> { +pub async fn run( + ctx: &CliContext, + session_id: &str, + output: PathBuf, + format: &str, +) -> anyhow::Result<()> { println!("{} Exporting session: {}", "→".green(), session_id.cyan()); println!(" Format: {}", format.yellow()); println!(" Output: {}", output.display().to_string().cyan()); println!(); - // TODO: Implement actual session export from persistence layer + let session = ctx + .session_manager + .get(session_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to load session: {}", e))? + .ok_or_else(|| anyhow::anyhow!("Session '{}' not found", session_id))?; + + let session_data = serde_json::json!({ + "session_id": session.key, + "created_at": session.created_at.to_rfc3339(), + "updated_at": session.updated_at.to_rfc3339(), + "metadata": session.metadata, + "messages": session.messages.iter().map(|m| { + serde_json::json!({ + "role": m.role, + "content": m.content, + "timestamp": m.timestamp.to_rfc3339(), + }) + }).collect::>(), + }); let output_str = match format { - "json" => { - let content = serde_json::json!({ - "session_id": session_id, - "agent_id": "agent-001", - "created_at": "2024-01-15T10:30:00Z", - "messages": [ - {"role": "user", "content": "Hello!"}, - {"role": "assistant", "content": "Hi there! How can I help you?"} - ], - "status": "active" - }); - serde_json::to_string_pretty(&content)? - } - "yaml" => { - format!( - "session_id: {}\nagent_id: agent-001\ncreated_at: 2024-01-15T10:30:00Z\nmessages:\n - role: user\n content: Hello!\n - role: assistant\n content: Hi there! How can I help you?\nstatus: active\n", - session_id - ) - } + "json" => serde_json::to_string_pretty(&session_data)?, + "yaml" => serde_yaml::to_string(&session_data)?, _ => anyhow::bail!("Unsupported export format: {}", format), }; diff --git a/crates/mofa-cli/src/commands/session/list.rs b/crates/mofa-cli/src/commands/session/list.rs index 4b30083a..897437f6 100644 --- a/crates/mofa-cli/src/commands/session/list.rs +++ b/crates/mofa-cli/src/commands/session/list.rs @@ -1,11 +1,16 @@ //! `mofa session list` command implementation +use crate::context::CliContext; use crate::output::Table; use colored::Colorize; use serde::Serialize; /// Execute the `mofa session list` command -pub fn run(agent_id: Option<&str>, limit: Option) -> anyhow::Result<()> { +pub async fn run( + ctx: &CliContext, + agent_id: Option<&str>, + limit: Option, +) -> anyhow::Result<()> { println!("{} Listing sessions", "→".green()); if let Some(agent) = agent_id { @@ -18,39 +23,59 @@ pub fn run(agent_id: Option<&str>, limit: Option) -> anyhow::Result<()> { println!(); - // TODO: Implement actual session listing from persistence layer - - let sessions = vec![ - SessionInfo { - session_id: "sess-001".to_string(), - agent_id: "agent-001".to_string(), - created_at: "2024-01-15 10:30:00".to_string(), - message_count: 12, - status: "active".to_string(), - }, - SessionInfo { - session_id: "sess-002".to_string(), - agent_id: "agent-001".to_string(), - created_at: "2024-01-15 09:15:00".to_string(), - message_count: 8, - status: "active".to_string(), - }, - ]; - - let filtered: Vec<_> = if let Some(agent) = agent_id { - sessions - .iter() - .filter(|s| s.agent_id == agent) - .cloned() - .collect() - } else { - sessions - }; + let keys = ctx + .session_manager + .list() + .await + .map_err(|e| anyhow::anyhow!("Failed to list sessions: {}", e))?; + + if keys.is_empty() { + println!(" No sessions found."); + return Ok(()); + } + + let mut sessions = Vec::new(); + for key in &keys { + let session = match ctx + .session_manager + .get(key) + .await + .map_err(|e| anyhow::anyhow!("Failed to load session '{}': {}", key, e))? + { + Some(session) => session, + None => continue, + }; + + // Filter by agent_id if provided (check metadata or key prefix) + if let Some(agent) = agent_id { + let matches = session + .metadata + .get("agent_id") + .and_then(|v| v.as_str()) + .map(|v| v == agent) + .unwrap_or_else(|| session.key.contains(agent)); + if !matches { + continue; + } + } + sessions.push(SessionInfo { + session_id: session.key.clone(), + created_at: session.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), + message_count: session.len(), + status: if session.is_empty() { + "empty".to_string() + } else { + "active".to_string() + }, + }); + } + + // Apply limit let limited: Vec<_> = if let Some(n) = limit { - filtered.into_iter().take(n).collect() + sessions.into_iter().take(n).collect() } else { - filtered + sessions }; if limited.is_empty() { @@ -70,8 +95,39 @@ pub fn run(agent_id: Option<&str>, limit: Option) -> anyhow::Result<()> { #[derive(Debug, Clone, Serialize)] struct SessionInfo { session_id: String, - agent_id: String, created_at: String, message_count: usize, status: String, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::CliContext; + use mofa_foundation::agent::session::Session; + use serde_json::json; + use tempfile::TempDir; + + #[tokio::test] + async fn test_session_list_runs_with_saved_sessions() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + let mut session_a = Session::new("agent-a:1"); + session_a + .metadata + .insert("agent_id".to_string(), json!("agent-a")); + session_a.add_message("user", "hello"); + ctx.session_manager.save(&session_a).await.unwrap(); + + let mut session_b = Session::new("agent-b:1"); + session_b + .metadata + .insert("agent_id".to_string(), json!("agent-b")); + ctx.session_manager.save(&session_b).await.unwrap(); + + run(&ctx, None, None).await.unwrap(); + run(&ctx, Some("agent-a"), None).await.unwrap(); + run(&ctx, Some("agent-b"), Some(1)).await.unwrap(); + } +} diff --git a/crates/mofa-cli/src/commands/session/show.rs b/crates/mofa-cli/src/commands/session/show.rs index ef94f290..08393c4c 100644 --- a/crates/mofa-cli/src/commands/session/show.rs +++ b/crates/mofa-cli/src/commands/session/show.rs @@ -1,50 +1,94 @@ //! `mofa session show` command implementation +use crate::context::CliContext; use colored::Colorize; /// Execute the `mofa session show` command -pub fn run(session_id: &str, format: Option<&str>) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, session_id: &str, format: Option<&str>) -> anyhow::Result<()> { println!("{} Session details: {}", "→".green(), session_id.cyan()); println!(); - // TODO: Implement actual session retrieval from persistence layer - + let session = ctx + .session_manager + .get(session_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to load session: {}", e))? + .ok_or_else(|| anyhow::anyhow!("Session '{}' not found", session_id))?; let output_format = format.unwrap_or("text"); match output_format { "json" => { let json = serde_json::json!({ - "session_id": session_id, - "agent_id": "agent-001", - "created_at": "2024-01-15T10:30:00Z", - "messages": [ - {"role": "user", "content": "Hello!"}, - {"role": "assistant", "content": "Hi there! How can I help you?"} - ], - "status": "active" + "session_id": session.key, + "created_at": session.created_at.to_rfc3339(), + "updated_at": session.updated_at.to_rfc3339(), + "message_count": session.len(), + "metadata": session.metadata, + "messages": session.messages.iter().map(|m| { + serde_json::json!({ + "role": m.role, + "content": m.content, + "timestamp": m.timestamp.to_rfc3339(), + }) + }).collect::>(), }); println!("{}", serde_json::to_string_pretty(&json)?); } "yaml" => { - println!("session_id: {}", session_id); - println!("agent_id: agent-001"); - println!("created_at: 2024-01-15T10:30:00Z"); - println!("messages:"); - println!(" - role: user"); - println!(" content: Hello!"); - println!(" - role: assistant"); - println!(" content: Hi there! How can I help you?"); - println!("status: active"); + let yaml = serde_json::json!({ + "session_id": session.key, + "created_at": session.created_at.to_rfc3339(), + "updated_at": session.updated_at.to_rfc3339(), + "message_count": session.len(), + "metadata": session.metadata, + "messages": session.messages.iter().map(|m| { + serde_json::json!({ + "role": m.role, + "content": m.content, + "timestamp": m.timestamp.to_rfc3339(), + }) + }).collect::>(), + }); + println!("{}", serde_yaml::to_string(&yaml)?); } _ => { - println!(" Session ID: {}", session_id.cyan()); - println!(" Agent ID: {}", "agent-001".white()); - println!(" Created: {}", "2024-01-15 10:30:00".white()); - println!(" Status: {}", "active".green()); + println!(" Session ID: {}", session.key.cyan()); + println!( + " Created: {}", + session + .created_at + .format("%Y-%m-%d %H:%M:%S") + .to_string() + .white() + ); + println!( + " Updated: {}", + session + .updated_at + .format("%Y-%m-%d %H:%M:%S") + .to_string() + .white() + ); + println!(" Messages: {}", session.len()); + if !session.metadata.is_empty() { + println!(" Metadata: {:?}", session.metadata); + } println!(); - println!(" Messages:"); - println!(" User: Hello!"); - println!(" Assistant: Hi there! How can I help you?"); + + if session.is_empty() { + println!(" (no messages)"); + } else { + println!(" Messages:"); + for msg in &session.messages { + let role_display = match msg.role.as_str() { + "user" => "User".green(), + "assistant" => "Assistant".cyan(), + "system" => "System".yellow(), + other => other.white(), + }; + println!(" {}: {}", role_display, msg.content); + } + } } } diff --git a/crates/mofa-cli/src/commands/tool/info.rs b/crates/mofa-cli/src/commands/tool/info.rs index 39607748..7f8cb0ce 100644 --- a/crates/mofa-cli/src/commands/tool/info.rs +++ b/crates/mofa-cli/src/commands/tool/info.rs @@ -1,23 +1,86 @@ //! `mofa tool info` command implementation +use crate::context::CliContext; use colored::Colorize; +use mofa_kernel::agent::components::tool::ToolRegistry; /// Execute the `mofa tool info` command -pub fn run(name: &str) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, name: &str) -> anyhow::Result<()> { println!("{} Tool information: {}", "→".green(), name.cyan()); println!(); - // TODO: Implement actual tool info lookup + match ctx.tool_registry.get(name) { + Some(tool) => { + let metadata = tool.metadata(); + println!(" Name: {}", tool.name().cyan()); + println!(" Description: {}", tool.description().white()); + if let Some(category) = &metadata.category { + println!(" Category: {}", category.white()); + } + if !metadata.tags.is_empty() { + println!(" Tags: {}", metadata.tags.join(", ").white()); + } + println!( + " Dangerous: {}", + if metadata.is_dangerous { + "Yes".red() + } else { + "No".green() + } + ); + println!( + " Needs network: {}", + if metadata.requires_network { + "Yes".yellow() + } else { + "No".white() + } + ); + println!( + " Needs FS: {}", + if metadata.requires_filesystem { + "Yes".yellow() + } else { + "No".white() + } + ); + println!( + " Confirmation: {}", + if tool.requires_confirmation() { + "Required".yellow() + } else { + "Not required".white() + } + ); - println!(" Name: {}", name.cyan()); - println!(" Description: {}", "A helpful tool".white()); - println!(" Version: {}", "1.0.0".white()); - println!(" Enabled: {}", "Yes".green()); - println!( - " Parameters: {}", - "query (required), limit (optional)".white() - ); - println!(); + // Show parameter schema + let schema = tool.parameters_schema(); + if !schema.is_null() { + println!(); + println!(" Parameters:"); + println!( + "{}", + serde_json::to_string_pretty(&schema)? + .lines() + .map(|l| format!(" {}", l)) + .collect::>() + .join("\n") + ); + } + + // Show source + if let Some(source) = ctx.tool_registry.get_source(name) { + println!(); + println!(" Source: {:?}", source); + } + } + None => { + println!(" Tool '{}' not found in registry", name); + println!(); + println!(" Use {} to see available tools.", "mofa tool list".cyan()); + } + } + println!(); Ok(()) } diff --git a/crates/mofa-cli/src/commands/tool/list.rs b/crates/mofa-cli/src/commands/tool/list.rs index 669ddb10..fe895d75 100644 --- a/crates/mofa-cli/src/commands/tool/list.rs +++ b/crates/mofa-cli/src/commands/tool/list.rs @@ -1,58 +1,43 @@ //! `mofa tool list` command implementation +use crate::context::CliContext; use crate::output::Table; use colored::Colorize; +use mofa_kernel::agent::components::tool::ToolRegistry; use serde::Serialize; /// Execute the `mofa tool list` command -pub fn run(available: bool, enabled: bool) -> anyhow::Result<()> { +pub async fn run(ctx: &CliContext, _available: bool, _enabled: bool) -> anyhow::Result<()> { println!("{} Listing tools", "→".green()); - - if available { - println!(" Showing available tools"); - } else if enabled { - println!(" Showing enabled tools"); - } - println!(); - // TODO: Implement actual tool discovery from tool registry - - let tools = vec![ - ToolInfo { - name: "web-search".to_string(), - description: "Search the web for information".to_string(), - enabled: true, - }, - ToolInfo { - name: "calculator".to_string(), - description: "Perform mathematical calculations".to_string(), - enabled: true, - }, - ToolInfo { - name: "code-executor".to_string(), - description: "Execute code in a sandboxed environment".to_string(), - enabled: false, - }, - ToolInfo { - name: "file-operations".to_string(), - description: "Read, write, and manipulate files".to_string(), - enabled: false, - }, - ]; - - let filtered: Vec<_> = if enabled { - tools.iter().filter(|t| t.enabled).cloned().collect() - } else { - tools - }; + let descriptors = ctx.tool_registry.list(); - if filtered.is_empty() { - println!(" No tools found."); + if descriptors.is_empty() { + println!(" No tools registered."); + println!(); + println!(" Tools can be registered programmatically via the SDK."); return Ok(()); } - let json = serde_json::to_value(&filtered)?; + let tools: Vec = descriptors + .iter() + .map(|d| { + let source = ctx + .tool_registry + .get_source(&d.name) + .map(|s| format!("{:?}", s)) + .unwrap_or_else(|| "unknown".to_string()); + ToolInfo { + name: d.name.clone(), + description: d.description.clone(), + category: d.metadata.category.clone().unwrap_or_default(), + source, + } + }) + .collect(); + + let json = serde_json::to_value(&tools)?; if let Some(arr) = json.as_array() { let table = Table::from_json_array(arr); println!("{}", table); @@ -65,5 +50,6 @@ pub fn run(available: bool, enabled: bool) -> anyhow::Result<()> { struct ToolInfo { name: String, description: String, - enabled: bool, + category: String, + source: String, } diff --git a/crates/mofa-cli/src/context.rs b/crates/mofa-cli/src/context.rs new file mode 100644 index 00000000..e0fd8b8c --- /dev/null +++ b/crates/mofa-cli/src/context.rs @@ -0,0 +1,417 @@ +//! CLI context providing access to backend services + +use crate::store::PersistedStore; +use crate::utils::paths; +use async_trait::async_trait; +use mofa_foundation::agent::base::BaseAgent; +use mofa_foundation::agent::components::tool::EchoTool; +use mofa_foundation::agent::session::SessionManager; +use mofa_foundation::agent::tools::registry::{ToolRegistry, ToolSource}; +use mofa_kernel::agent::AgentCapabilities; +use mofa_kernel::agent::config::AgentConfig; +use mofa_kernel::agent::core::MoFAAgent; +use mofa_kernel::agent::error::{AgentError, AgentResult}; +use mofa_kernel::agent::plugins::PluginRegistry; +use mofa_runtime::agent::AgentFactory; +use mofa_runtime::agent::plugins::{HttpPlugin, SimplePluginRegistry}; +use mofa_runtime::agent::registry::AgentRegistry; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::RwLock; + +const BUILTIN_HTTP_PLUGIN_KIND: &str = "builtin:http"; +const BUILTIN_ECHO_TOOL_KIND: &str = "builtin:echo"; +const CLI_BASE_FACTORY_KIND: &str = "cli-base"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentConfigEntry { + pub id: String, + pub name: String, + pub state: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginSpecEntry { + pub id: String, + pub kind: String, + pub enabled: bool, + pub config: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolSpecEntry { + pub id: String, + pub kind: String, + pub enabled: bool, + pub config: Value, +} + +/// Shared context for CLI commands, holding references to backend services +pub struct CliContext { + /// Session manager with file-based persistence + pub session_manager: SessionManager, + /// In-memory agent registry + pub agent_registry: AgentRegistry, + /// Persistent agent metadata store + pub agent_store: PersistedStore, + /// Persistent plugin source specifications + pub plugin_store: PersistedStore, + /// Persistent tool source specifications + pub tool_store: PersistedStore, + /// In-memory plugin registry + pub plugin_registry: Arc, + /// In-memory tool registry + pub tool_registry: ToolRegistry, + /// Platform-specific data directory (~/.local/share/mofa or equivalent) + pub data_dir: PathBuf, + /// Platform-specific config directory (~/.config/mofa or equivalent) + pub config_dir: PathBuf, +} + +impl CliContext { + /// Initialize the CLI context with default backend services + pub async fn new() -> anyhow::Result { + let data_dir = paths::ensure_mofa_data_dir()?; + let config_dir = paths::ensure_mofa_config_dir()?; + migrate_legacy_nested_sessions(&data_dir)?; + + let session_manager = SessionManager::with_jsonl(&data_dir) + .await + .map_err(|e| anyhow::anyhow!("Failed to initialize session manager: {}", e))?; + let agent_store = PersistedStore::new(data_dir.join("agents"))?; + let agent_registry = AgentRegistry::new(); + register_default_agent_factories(&agent_registry).await?; + let plugin_store = PersistedStore::new(data_dir.join("plugins"))?; + let tool_store = PersistedStore::new(data_dir.join("tools"))?; + seed_default_specs(&plugin_store, &tool_store)?; + + let plugin_registry = Arc::new(SimplePluginRegistry::new()); + replay_persisted_plugins(&plugin_registry, &plugin_store)?; + let mut tool_registry = ToolRegistry::new(); + replay_persisted_tools(&mut tool_registry, &tool_store)?; + + Ok(Self { + session_manager, + agent_registry, + agent_store, + plugin_store, + tool_store, + plugin_registry, + tool_registry, + data_dir, + config_dir, + }) + } +} + +#[cfg(test)] +impl CliContext { + pub async fn with_temp_dir(temp_dir: &std::path::Path) -> anyhow::Result { + let data_dir = temp_dir.join("data"); + let config_dir = temp_dir.join("config"); + std::fs::create_dir_all(&data_dir)?; + std::fs::create_dir_all(&config_dir)?; + migrate_legacy_nested_sessions(&data_dir)?; + + let session_manager = SessionManager::with_jsonl(&data_dir) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + let agent_store = PersistedStore::new(data_dir.join("agents"))?; + let agent_registry = AgentRegistry::new(); + register_default_agent_factories(&agent_registry).await?; + let plugin_store = PersistedStore::new(data_dir.join("plugins"))?; + let tool_store = PersistedStore::new(data_dir.join("tools"))?; + seed_default_specs(&plugin_store, &tool_store)?; + + let plugin_registry = Arc::new(SimplePluginRegistry::new()); + replay_persisted_plugins(&plugin_registry, &plugin_store)?; + let mut tool_registry = ToolRegistry::new(); + replay_persisted_tools(&mut tool_registry, &tool_store)?; + + Ok(Self { + session_manager, + agent_registry, + agent_store, + plugin_store, + tool_store, + plugin_registry, + tool_registry, + data_dir, + config_dir, + }) + } +} + +struct CliBaseAgentFactory; + +#[async_trait] +impl AgentFactory for CliBaseAgentFactory { + async fn create(&self, config: AgentConfig) -> AgentResult>> { + let mut agent = + BaseAgent::new(config.id, config.name).with_capabilities(self.default_capabilities()); + + if let Some(description) = config.description { + agent = agent.with_description(description); + } + + if let Some(version) = config.version { + agent = agent.with_version(version); + } + + Ok(Arc::new(RwLock::new(agent))) + } + + fn type_id(&self) -> &str { + CLI_BASE_FACTORY_KIND + } + + fn default_capabilities(&self) -> AgentCapabilities { + AgentCapabilities::builder().tag("cli").tag("base").build() + } + + fn validate_config(&self, config: &AgentConfig) -> AgentResult<()> { + if config.id.trim().is_empty() { + return Err(AgentError::ConfigError( + "Agent id cannot be empty".to_string(), + )); + } + if config.name.trim().is_empty() { + return Err(AgentError::ConfigError( + "Agent name cannot be empty".to_string(), + )); + } + if !config.enabled { + return Err(AgentError::ConfigError( + "Cannot start disabled agent config".to_string(), + )); + } + Ok(()) + } + + fn description(&self) -> Option<&str> { + Some("Default CLI base-agent factory") + } +} + +async fn register_default_agent_factories(agent_registry: &AgentRegistry) -> anyhow::Result<()> { + if agent_registry + .list_factory_types() + .await + .iter() + .any(|kind| kind == CLI_BASE_FACTORY_KIND) + { + return Ok(()); + } + + agent_registry + .register_factory(Arc::new(CliBaseAgentFactory)) + .await + .map_err(|e| anyhow::anyhow!("Failed to register default agent factory: {}", e))?; + + Ok(()) +} + +fn seed_default_specs( + plugin_store: &PersistedStore, + tool_store: &PersistedStore, +) -> anyhow::Result<()> { + let default_plugin = PluginSpecEntry { + id: "http-plugin".to_string(), + kind: BUILTIN_HTTP_PLUGIN_KIND.to_string(), + enabled: true, + config: serde_json::json!({ + "url": "https://example.com", + }), + }; + if plugin_store.get(&default_plugin.id)?.is_none() { + plugin_store.save(&default_plugin.id, &default_plugin)?; + } + + let default_tool = ToolSpecEntry { + id: "echo".to_string(), + kind: BUILTIN_ECHO_TOOL_KIND.to_string(), + enabled: true, + config: Value::Null, + }; + if tool_store.get(&default_tool.id)?.is_none() { + tool_store.save(&default_tool.id, &default_tool)?; + } + + Ok(()) +} + +fn replay_persisted_plugins( + plugin_registry: &Arc, + plugin_store: &PersistedStore, +) -> anyhow::Result<()> { + for (_, spec) in plugin_store.list()? { + if !spec.enabled { + continue; + } + + match spec.kind.as_str() { + BUILTIN_HTTP_PLUGIN_KIND => { + let url = spec + .config + .get("url") + .and_then(|v| v.as_str()) + .unwrap_or("https://example.com"); + plugin_registry + .register(Arc::new(HttpPlugin::new(url))) + .map_err(|e| { + anyhow::anyhow!("Failed to register plugin '{}': {}", spec.id, e) + })?; + } + _ => { + // Ignore unknown kinds for forward compatibility. + } + } + } + + Ok(()) +} + +fn replay_persisted_tools( + tool_registry: &mut ToolRegistry, + tool_store: &PersistedStore, +) -> anyhow::Result<()> { + for (_, spec) in tool_store.list()? { + if !spec.enabled { + continue; + } + + match spec.kind.as_str() { + BUILTIN_ECHO_TOOL_KIND => { + tool_registry + .register_with_source(Arc::new(EchoTool), ToolSource::Builtin) + .map_err(|e| anyhow::anyhow!("Failed to register tool '{}': {}", spec.id, e))?; + } + _ => { + // Ignore unknown kinds for forward compatibility. + } + } + } + + Ok(()) +} + +fn migrate_legacy_nested_sessions(data_dir: &Path) -> anyhow::Result<()> { + let sessions_dir = data_dir.join("sessions"); + let legacy_dir = sessions_dir.join("sessions"); + if !legacy_dir.exists() { + return Ok(()); + } + + std::fs::create_dir_all(&sessions_dir)?; + for entry in std::fs::read_dir(&legacy_dir)? { + let entry = entry?; + let src = entry.path(); + let dst = sessions_dir.join(entry.file_name()); + + if dst.exists() { + continue; + } + + std::fs::rename(&src, &dst)?; + } + + let _ = std::fs::remove_dir(&legacy_dir); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use mofa_foundation::agent::session::Session; + use mofa_kernel::agent::components::tool::ToolRegistry as ToolRegistryTrait; + use mofa_kernel::agent::plugins::PluginRegistry as PluginRegistryTrait; + use tempfile::TempDir; + + #[tokio::test] + async fn test_agent_store_persists_across_context_instances() { + let temp = TempDir::new().unwrap(); + + let ctx1 = CliContext::with_temp_dir(temp.path()).await.unwrap(); + let entry = AgentConfigEntry { + id: "persisted-agent".to_string(), + name: "Persisted Agent".to_string(), + state: "Running".to_string(), + description: None, + }; + ctx1.agent_store.save("persisted-agent", &entry).unwrap(); + drop(ctx1); + + let ctx2 = CliContext::with_temp_dir(temp.path()).await.unwrap(); + let loaded = ctx2.agent_store.get("persisted-agent").unwrap(); + + assert!(loaded.is_some()); + assert_eq!(loaded.unwrap().id, "persisted-agent"); + } + + #[tokio::test] + async fn test_legacy_nested_sessions_are_migrated() { + let temp = TempDir::new().unwrap(); + let data_dir = temp.path().join("data"); + std::fs::create_dir_all(&data_dir).unwrap(); + + let legacy_manager = SessionManager::with_jsonl(data_dir.join("sessions")) + .await + .unwrap(); + let mut session = Session::new("legacy-session"); + session.add_message("user", "hello"); + legacy_manager.save(&session).await.unwrap(); + + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + let loaded = ctx.session_manager.get("legacy-session").await.unwrap(); + + assert!(loaded.is_some()); + assert!( + data_dir + .join("sessions") + .join("legacy-session.jsonl") + .exists() + ); + } + + #[tokio::test] + async fn test_plugin_and_tool_specs_replayed_on_startup() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + assert!(PluginRegistryTrait::contains( + ctx.plugin_registry.as_ref(), + "http-plugin" + )); + assert!(ToolRegistryTrait::contains(&ctx.tool_registry, "echo")); + } + + #[tokio::test] + async fn test_disabled_plugin_spec_is_not_replayed() { + let temp = TempDir::new().unwrap(); + + let ctx1 = CliContext::with_temp_dir(temp.path()).await.unwrap(); + let mut spec = ctx1.plugin_store.get("http-plugin").unwrap().unwrap(); + spec.enabled = false; + ctx1.plugin_store.save("http-plugin", &spec).unwrap(); + drop(ctx1); + + let ctx2 = CliContext::with_temp_dir(temp.path()).await.unwrap(); + assert!(!PluginRegistryTrait::contains( + ctx2.plugin_registry.as_ref(), + "http-plugin" + )); + } + + #[tokio::test] + async fn test_default_agent_factory_registered_on_startup() { + let temp = TempDir::new().unwrap(); + let ctx = CliContext::with_temp_dir(temp.path()).await.unwrap(); + + let factory_types = ctx.agent_registry.list_factory_types().await; + assert!(factory_types.iter().any(|k| k == CLI_BASE_FACTORY_KIND)); + } +} diff --git a/crates/mofa-cli/src/main.rs b/crates/mofa-cli/src/main.rs index 2465b1df..9462cc9b 100644 --- a/crates/mofa-cli/src/main.rs +++ b/crates/mofa-cli/src/main.rs @@ -3,8 +3,10 @@ mod cli; mod commands; mod config; +mod context; mod output; mod render; +mod store; mod tui; mod utils; mod widgets; @@ -12,6 +14,7 @@ mod widgets; use clap::Parser; use cli::Cli; use colored::Colorize; +use context::CliContext; fn main() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -23,20 +26,39 @@ fn main() -> anyhow::Result<()> { tracing_subscriber::fmt().with_env_filter("info").init(); } + let rt = tokio::runtime::Runtime::new()?; + // Launch TUI if requested or no command provided if cli.tui || cli.command.is_none() { // Run TUI mode - tokio::runtime::Runtime::new()?.block_on(tui::run())?; + rt.block_on(tui::run())?; Ok(()) } else { - // Run CLI command as usual - run_command(cli) + // Run CLI command + rt.block_on(run_command(cli)) } } -fn run_command(cli: Cli) -> anyhow::Result<()> { +async fn run_command(cli: Cli) -> anyhow::Result<()> { use cli::Commands; + // Initialize context for commands that need backend services + let needs_context = matches!( + &cli.command, + Some( + Commands::Agent(_) + | Commands::Plugin { .. } + | Commands::Session { .. } + | Commands::Tool { .. } + ) + ); + + let ctx = if needs_context { + Some(CliContext::new().await?) + } else { + None + }; + match cli.command { Some(Commands::New { name, @@ -89,33 +111,44 @@ fn run_command(cli: Cli) -> anyhow::Result<()> { } }, - Some(Commands::Agent(agent_cmd)) => match agent_cmd { - cli::AgentCommands::Create { - non_interactive, - config, - } => { - commands::agent::create::run(non_interactive, config)?; - } - cli::AgentCommands::Start { - agent_id, - config, - daemon, - } => { - commands::agent::start::run(&agent_id, config.as_deref(), daemon)?; - } - cli::AgentCommands::Stop { agent_id } => { - commands::agent::stop::run(&agent_id)?; - } - cli::AgentCommands::Restart { agent_id, config } => { - commands::agent::restart::run(&agent_id, config.as_deref())?; - } - cli::AgentCommands::Status { agent_id } => { - commands::agent::status::run(agent_id.as_deref())?; - } - cli::AgentCommands::List { running, all } => { - commands::agent::list::run(running, all)?; + Some(Commands::Agent(agent_cmd)) => { + let ctx = ctx.as_ref().unwrap(); + match agent_cmd { + cli::AgentCommands::Create { + non_interactive, + config, + } => { + commands::agent::create::run(non_interactive, config)?; + } + cli::AgentCommands::Start { + agent_id, + config, + factory_type, + daemon, + } => { + commands::agent::start::run( + ctx, + &agent_id, + config.as_deref(), + factory_type.as_deref(), + daemon, + ) + .await?; + } + cli::AgentCommands::Stop { agent_id } => { + commands::agent::stop::run(ctx, &agent_id).await?; + } + cli::AgentCommands::Restart { agent_id, config } => { + commands::agent::restart::run(ctx, &agent_id, config.as_deref()).await?; + } + cli::AgentCommands::Status { agent_id } => { + commands::agent::status::run(ctx, agent_id.as_deref()).await?; + } + cli::AgentCommands::List { running, all } => { + commands::agent::list::run(ctx, running, all).await?; + } } - }, + } Some(Commands::Config { action }) => match action { cli::ConfigCommands::Value(value_cmd) => match value_cmd { @@ -140,51 +173,63 @@ fn run_command(cli: Cli) -> anyhow::Result<()> { } }, - Some(Commands::Plugin { action }) => match action { - cli::PluginCommands::List { - installed, - available, - } => { - commands::plugin::list::run(installed, available)?; - } - cli::PluginCommands::Info { name } => { - commands::plugin::info::run(&name)?; - } - cli::PluginCommands::Uninstall { name, force } => { - commands::plugin::uninstall::run(&name, force)?; + Some(Commands::Plugin { action }) => { + let ctx = ctx.as_ref().unwrap(); + match action { + cli::PluginCommands::List { + installed, + available, + } => { + commands::plugin::list::run(ctx, installed, available).await?; + } + cli::PluginCommands::Info { name } => { + commands::plugin::info::run(ctx, &name).await?; + } + cli::PluginCommands::Uninstall { name, force } => { + commands::plugin::uninstall::run(ctx, &name, force).await?; + } } - }, + } - Some(Commands::Session { action }) => match action { - cli::SessionCommands::List { agent, limit } => { - commands::session::list::run(agent.as_deref(), limit)?; - } - cli::SessionCommands::Show { session_id, format } => { - commands::session::show::run( - &session_id, - format.map(|f| f.to_string()).as_deref(), - )?; - } - cli::SessionCommands::Delete { session_id, force } => { - commands::session::delete::run(&session_id, force)?; - } - cli::SessionCommands::Export { - session_id, - output, - format, - } => { - commands::session::export::run(&session_id, output, &format.to_string())?; + Some(Commands::Session { action }) => { + let ctx = ctx.as_ref().unwrap(); + match action { + cli::SessionCommands::List { agent, limit } => { + commands::session::list::run(ctx, agent.as_deref(), limit).await?; + } + cli::SessionCommands::Show { session_id, format } => { + commands::session::show::run( + ctx, + &session_id, + format.map(|f| f.to_string()).as_deref(), + ) + .await?; + } + cli::SessionCommands::Delete { session_id, force } => { + commands::session::delete::run(ctx, &session_id, force).await?; + } + cli::SessionCommands::Export { + session_id, + output, + format, + } => { + commands::session::export::run(ctx, &session_id, output, &format.to_string()) + .await?; + } } - }, + } - Some(Commands::Tool { action }) => match action { - cli::ToolCommands::List { available, enabled } => { - commands::tool::list::run(available, enabled)?; - } - cli::ToolCommands::Info { name } => { - commands::tool::info::run(&name)?; + Some(Commands::Tool { action }) => { + let ctx = ctx.as_ref().unwrap(); + match action { + cli::ToolCommands::List { available, enabled } => { + commands::tool::list::run(ctx, available, enabled).await?; + } + cli::ToolCommands::Info { name } => { + commands::tool::info::run(ctx, &name).await?; + } } - }, + } None => { // Should have been handled by TUI check above diff --git a/crates/mofa-cli/src/store.rs b/crates/mofa-cli/src/store.rs new file mode 100644 index 00000000..ae0c8473 --- /dev/null +++ b/crates/mofa-cli/src/store.rs @@ -0,0 +1,243 @@ +//! Generic file-based persisted store for CLI state. + +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::fs; +use std::marker::PhantomData; +use std::path::{Path, PathBuf}; + +pub struct PersistedStore { + dir: PathBuf, + _phantom: PhantomData, +} + +impl PersistedStore { + pub fn new(dir: impl AsRef) -> anyhow::Result { + let dir = dir.as_ref().to_path_buf(); + fs::create_dir_all(&dir)?; + Ok(Self { + dir, + _phantom: PhantomData, + }) + } + + pub fn save(&self, id: &str, item: &T) -> anyhow::Result<()> { + let path = self.path_for(id); + let payload = serde_json::to_vec_pretty(item)?; + fs::write(path, payload)?; + Ok(()) + } + + pub fn get(&self, id: &str) -> anyhow::Result> { + let path = self.path_for(id); + if !path.exists() { + return Ok(None); + } + + let payload = fs::read(path)?; + let item = serde_json::from_slice(&payload)?; + Ok(Some(item)) + } + + pub fn list(&self) -> anyhow::Result> { + let mut items = Vec::new(); + + for entry in fs::read_dir(&self.dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + + let id = match path.file_stem().and_then(|stem| stem.to_str()) { + Some(stem) => stem.to_string(), + None => continue, + }; + + let payload = fs::read(path)?; + let item = serde_json::from_slice(&payload)?; + items.push((id, item)); + } + + items.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(items) + } + + pub fn delete(&self, id: &str) -> anyhow::Result { + let path = self.path_for(id); + if !path.exists() { + return Ok(false); + } + + fs::remove_file(path)?; + Ok(true) + } + + fn path_for(&self, id: &str) -> PathBuf { + let safe_id: String = id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { + c + } else { + '_' + } + }) + .collect(); + + let file_name = if safe_id.is_empty() { + "_".to_string() + } else { + safe_id + }; + + self.dir.join(format!("{}.json", file_name)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)] + struct TestEntry { + name: String, + value: u32, + } + + #[test] + fn test_save_and_get() { + let temp = TempDir::new().unwrap(); + let store = PersistedStore::::new(temp.path()).unwrap(); + let entry = TestEntry { + name: "alpha".to_string(), + value: 1, + }; + + store.save("alpha", &entry).unwrap(); + let loaded = store.get("alpha").unwrap(); + assert_eq!(loaded, Some(entry)); + } + + #[test] + fn test_get_returns_none_for_missing() { + let temp = TempDir::new().unwrap(); + let store = PersistedStore::::new(temp.path()).unwrap(); + + assert!(store.get("missing").unwrap().is_none()); + } + + #[test] + fn test_list_returns_all() { + let temp = TempDir::new().unwrap(); + let store = PersistedStore::::new(temp.path()).unwrap(); + store + .save( + "a", + &TestEntry { + name: "a".to_string(), + value: 1, + }, + ) + .unwrap(); + store + .save( + "b", + &TestEntry { + name: "b".to_string(), + value: 2, + }, + ) + .unwrap(); + + let items = store.list().unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items[0].0, "a"); + assert_eq!(items[1].0, "b"); + } + + #[test] + fn test_delete() { + let temp = TempDir::new().unwrap(); + let store = PersistedStore::::new(temp.path()).unwrap(); + store + .save( + "x", + &TestEntry { + name: "x".to_string(), + value: 9, + }, + ) + .unwrap(); + + assert!(store.delete("x").unwrap()); + assert!(store.get("x").unwrap().is_none()); + } + + #[test] + fn test_delete_nonexistent_returns_false() { + let temp = TempDir::new().unwrap(); + let store = PersistedStore::::new(temp.path()).unwrap(); + + assert!(!store.delete("ghost").unwrap()); + } + + #[test] + fn test_overwrite() { + let temp = TempDir::new().unwrap(); + let store = PersistedStore::::new(temp.path()).unwrap(); + + store + .save( + "k", + &TestEntry { + name: "old".to_string(), + value: 1, + }, + ) + .unwrap(); + store + .save( + "k", + &TestEntry { + name: "new".to_string(), + value: 2, + }, + ) + .unwrap(); + + let loaded = store.get("k").unwrap().unwrap(); + assert_eq!(loaded.name, "new"); + assert_eq!(loaded.value, 2); + } + + #[test] + fn test_survives_new_instance() { + let temp = TempDir::new().unwrap(); + let path = temp.path().to_path_buf(); + + { + let store = PersistedStore::::new(&path).unwrap(); + store + .save( + "persisted", + &TestEntry { + name: "persisted".to_string(), + value: 7, + }, + ) + .unwrap(); + } + + let new_store = PersistedStore::::new(&path).unwrap(); + let loaded = new_store.get("persisted").unwrap(); + assert_eq!( + loaded, + Some(TestEntry { + name: "persisted".to_string(), + value: 7 + }) + ); + } +} diff --git a/crates/mofa-foundation/src/agent/session.rs b/crates/mofa-foundation/src/agent/session.rs index 005b06b6..269eb304 100644 --- a/crates/mofa-foundation/src/agent/session.rs +++ b/crates/mofa-foundation/src/agent/session.rs @@ -9,6 +9,7 @@ use serde_json::Value; use std::collections::HashMap; use std::path::{Path, PathBuf}; use tokio::fs; +use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::sync::RwLock; use mofa_kernel::agent::error::{AgentError, AgentResult}; @@ -271,10 +272,27 @@ impl SessionStorage for JsonlSessionStorage { .await .map_err(|e| AgentError::IoError(format!("Failed to read entry: {}", e)))? { - if let Some(name) = entry.path().file_stem().and_then(|s| s.to_str()) { - // Convert back the safe filename to original key - let key = name.replace('_', ":"); - keys.push(key); + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("jsonl") { + continue; + } + + if let Ok(file) = fs::File::open(&path).await { + let mut reader = BufReader::new(file); + let mut header = String::new(); + if reader.read_line(&mut header).await.is_ok() { + let header = header.trim_end(); + if let Ok(header_data) = serde_json::from_str::(header) { + if let Some(key) = header_data.get("key").and_then(|v| v.as_str()) { + keys.push(key.to_string()); + continue; + } + } + } + } + + if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { + keys.push(name.to_string()); } } @@ -357,6 +375,18 @@ impl SessionManager { } } + /// Get a session by key without creating a new one + pub async fn get(&self, key: &str) -> AgentResult> { + { + let cache = self.cache.read().await; + if let Some(session) = cache.get(key) { + return Ok(Some(session.clone())); + } + } + + self.storage.load(key).await + } + /// Get or create a session pub async fn get_or_create(&self, key: &str) -> Session { // Try cache first @@ -469,4 +499,88 @@ mod tests { let loaded = manager.get_or_create("test:manager").await; assert_eq!(loaded.key, "test:manager"); } + + #[tokio::test] + async fn test_session_get_returns_none_for_missing() { + let temp_dir = TempDir::new().unwrap(); + let manager = SessionManager::with_jsonl(temp_dir.path()).await.unwrap(); + + let result = manager.get("nonexistent").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_session_get_returns_some_for_existing() { + let temp_dir = TempDir::new().unwrap(); + let manager = SessionManager::with_jsonl(temp_dir.path()).await.unwrap(); + + let mut session = Session::new("exists"); + session.add_message("user", "hello"); + manager.save(&session).await.unwrap(); + + let result = manager.get("exists").await.unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().len(), 1); + } + + #[tokio::test] + async fn test_session_get_does_not_create() { + let temp_dir = TempDir::new().unwrap(); + let manager = SessionManager::with_jsonl(temp_dir.path()).await.unwrap(); + + let _ = manager.get("phantom").await.unwrap(); + let keys = manager.list().await.unwrap(); + assert!(!keys.contains(&"phantom".to_string())); + } + + #[tokio::test] + async fn test_session_storage_path_no_double_nesting() { + let temp_dir = TempDir::new().unwrap(); + let manager = SessionManager::with_jsonl(temp_dir.path()).await.unwrap(); + + let mut session = Session::new("nesting-test"); + session.add_message("user", "hello"); + manager.save(&session).await.unwrap(); + + assert!( + temp_dir + .path() + .join("sessions") + .join("nesting-test.jsonl") + .exists() + ); + assert!(!temp_dir.path().join("sessions").join("sessions").exists()); + } + + #[tokio::test] + async fn test_session_list_preserves_underscore_keys() { + let temp_dir = TempDir::new().unwrap(); + let manager = SessionManager::with_jsonl(temp_dir.path()).await.unwrap(); + + let mut session = Session::new("team_alpha"); + session.add_message("user", "hello"); + manager.save(&session).await.unwrap(); + + let keys = manager.list().await.unwrap(); + assert!(keys.contains(&"team_alpha".to_string())); + assert!(!keys.contains(&"team:alpha".to_string())); + } + + #[tokio::test] + async fn test_session_list_prefers_header_key_without_loading_whole_file() { + let temp_dir = TempDir::new().unwrap(); + let manager = SessionManager::with_jsonl(temp_dir.path()).await.unwrap(); + + let sessions_dir = temp_dir.path().join("sessions"); + tokio::fs::write( + sessions_dir.join("alias.jsonl"), + "{\"key\":\"canonical:key\"}\n{\"role\":\"user\",\"content\":\"hello\"}\n", + ) + .await + .unwrap(); + + let keys = manager.list().await.unwrap(); + assert!(keys.contains(&"canonical:key".to_string())); + assert!(!keys.contains(&"alias".to_string())); + } }