use uuid::Uuid;
use crate::brain::agent::AgentService;
use crate::config::Config;
use crate::db::repository::SessionListOptions;
use crate::services::SessionService;
pub async fn sync_provider_for_session(
agent: &AgentService,
session_id: Uuid,
session_provider: Option<&str>,
session_model: Option<&str>,
) {
let config = match Config::load() {
Ok(c) => c,
Err(e) => {
tracing::warn!(
"sync_provider_for_session[{}]: Config::load failed: {} — skipping sync",
session_id,
e
);
return;
}
};
if let Some(sess_prov) = session_provider {
let agent_provider = agent.provider_name_for_session(session_id);
let agent_model = agent.provider_model_for_session(session_id);
let sess_prov_norm = normalize_provider_name(sess_prov);
let agent_prov_norm = normalize_provider_name(&agent_provider);
let same_provider = provider_names_match(&sess_prov_norm, &agent_prov_norm);
let same_model = session_model.is_none_or(|m| m == agent_model);
if same_provider && same_model {
tracing::debug!(
"sync_provider_for_session[{}]: already on {}/{} — no swap needed",
session_id,
agent_provider,
agent_model
);
return;
}
tracing::info!(
"sync_provider_for_session[{}]: session wants {}/{}, agent currently on {}/{} — attempting restore",
session_id,
sess_prov,
session_model.unwrap_or("<default>"),
agent_provider,
agent_model,
);
let has_key = config
.providers
.custom
.as_ref()
.and_then(|c| c.get(sess_prov))
.and_then(|p| p.api_key.as_ref())
.is_some_and(|k| !k.is_empty() && k != "__EXISTING_KEY__");
tracing::info!(
"sync_provider_for_session[{}]: create_provider_by_name('{}') — config has api_key: {}",
session_id,
sess_prov,
has_key
);
match crate::brain::provider::factory::create_provider_by_name(&config, sess_prov).await {
Ok(new_provider) => {
tracing::info!(
"sync_provider_for_session[{}]: restored {}/{} (was {}/{})",
session_id,
sess_prov,
session_model.unwrap_or("<default>"),
agent_provider,
agent_model,
);
agent.swap_provider_for_session(session_id, new_provider);
}
Err(e) => {
tracing::warn!(
"sync_provider_for_session[{}]: create_provider_by_name('{}') failed: {} — keeping current provider (session isolation)",
session_id,
sess_prov,
e
);
}
}
} else {
tracing::debug!(
"sync_provider_for_session[{}]: session has no stored provider — using global config",
session_id
);
let (cfg_provider, cfg_model) = config.providers.active_provider_and_model();
let agent_provider = agent.provider_name_for_session(session_id);
let agent_model = agent.provider_model_for_session(session_id);
let cfg_provider_norm = normalize_provider_name(&cfg_provider);
let agent_provider_norm = normalize_provider_name(&agent_provider);
let same_provider = provider_names_match(&cfg_provider_norm, &agent_provider_norm);
if !same_provider || cfg_model != agent_model {
match crate::brain::provider::create_provider(&config).await {
Ok(new_provider) => {
tracing::info!(
"sync_provider_for_session[{}]: synced to config provider {} (was {})",
session_id,
cfg_provider,
agent_provider,
);
agent.swap_provider_for_session(session_id, new_provider);
}
Err(e) => {
tracing::warn!(
"sync_provider_for_session[{}]: create_provider(active config) failed: {}",
session_id,
e
);
}
}
}
}
}
fn normalize_provider_name(name: &str) -> String {
crate::utils::providers::normalize_provider_name(name)
}
fn provider_names_match(config_provider: &str, runtime_provider: &str) -> bool {
config_provider == runtime_provider
|| config_provider
.strip_prefix("custom:")
.is_some_and(|name| name == runtime_provider)
}
pub enum ChannelCommand {
Help(String),
Usage(String),
Models(ProvidersResponse),
NewSession,
Sessions(SessionsResponse),
Stop,
Compact,
Doctor,
Evolve,
UserPrompt(String),
UserSystem(String),
NotACommand,
}
pub struct ProvidersResponse {
pub current_provider: String,
pub current_model: String,
pub providers: Vec<(String, String)>,
pub text: String,
}
pub struct SessionsResponse {
pub current_session_id: Uuid,
pub sessions: Vec<(Uuid, String)>,
pub text: String,
}
pub struct ModelsResponse {
pub provider_name: String,
pub current_model: String,
pub models: Vec<String>,
pub text: String,
pub agent_handled: bool,
}
pub async fn handle_command(
text: &str,
session_id: Uuid,
agent: &AgentService,
session_svc: &SessionService,
) -> ChannelCommand {
let trimmed = text.trim();
let result = match trimmed {
"/compact" => ChannelCommand::Compact,
"/doctor" => ChannelCommand::Doctor,
"/evolve" => ChannelCommand::Evolve,
"/help" => ChannelCommand::Help(format_help()),
"/models" => ChannelCommand::Models(format_providers(agent)),
"/new" => ChannelCommand::NewSession,
"/sessions" => ChannelCommand::Sessions(format_sessions(session_id, session_svc).await),
"/stop" => ChannelCommand::Stop,
"/usage" => ChannelCommand::Usage(format_usage(session_id, agent, session_svc).await),
_ if trimmed.starts_with('/') && !crate::utils::string::looks_like_file_path(trimmed) => {
match_user_command(trimmed)
}
_ => ChannelCommand::NotACommand,
};
let response_text = match &result {
ChannelCommand::Help(body) | ChannelCommand::Usage(body) => Some(body.clone()),
ChannelCommand::Models(resp) => Some(resp.text.clone()),
ChannelCommand::Sessions(resp) => Some(resp.text.clone()),
ChannelCommand::NewSession => Some("New session started.".to_string()),
ChannelCommand::Stop => Some("Operation stopped.".to_string()),
ChannelCommand::UserSystem(body) => Some(body.clone()),
ChannelCommand::Doctor => Some("Running health check...".to_string()),
ChannelCommand::Evolve => Some("Checking for updates...".to_string()),
ChannelCommand::Compact | ChannelCommand::UserPrompt(_) | ChannelCommand::NotACommand => {
None
}
};
if let Some(response) = response_text {
persist_command_to_history(agent, session_id, trimmed, &response).await;
}
result
}
async fn persist_command_to_history(
agent: &AgentService,
session_id: Uuid,
command: &str,
response: &str,
) {
let msg_svc = crate::services::MessageService::new(agent.context().clone());
if let Err(e) = msg_svc
.create_message(session_id, "user".to_string(), command.to_string())
.await
{
tracing::warn!("Failed to persist channel command to history: {}", e);
}
if let Err(e) = msg_svc
.create_message(session_id, "assistant".to_string(), response.to_string())
.await
{
tracing::warn!(
"Failed to persist channel command response to history: {}",
e
);
}
if let Some(tx) = agent.session_updated_tx() {
let _ = tx.send(crate::brain::agent::ChannelSessionEvent::Updated(
session_id,
));
}
}
fn match_user_command(text: &str) -> ChannelCommand {
let brain_path = crate::brain::BrainLoader::resolve_path();
let loader = crate::brain::CommandLoader::from_brain_path(&brain_path);
let commands = loader.load();
let skills = crate::brain::skills::load_all_skills();
match_user_command_inner(text, &commands, &skills)
}
pub(crate) fn match_user_command_inner(
text: &str,
commands: &[crate::brain::commands::UserCommand],
skills: &[crate::brain::skills::Skill],
) -> ChannelCommand {
let (cmd_name, args) = text
.split_once(' ')
.map(|(c, a)| (c, a.trim()))
.unwrap_or((text, ""));
if let Some(cmd) = commands.iter().find(|c| c.name == cmd_name) {
let prompt = if args.is_empty() {
cmd.prompt.clone()
} else {
format!("{} {}", cmd.prompt, args)
};
return match cmd.action.as_str() {
"system" => ChannelCommand::UserSystem(prompt),
_ => ChannelCommand::UserPrompt(prompt),
};
}
if let Some(skill) = skills.iter().find(|s| s.slash_name == cmd_name) {
let prompt = if args.is_empty() {
skill.body.clone()
} else {
format!("{}\n\n{}", skill.body, args)
};
return ChannelCommand::UserPrompt(prompt);
}
ChannelCommand::NotACommand
}
fn format_help() -> String {
let mut lines = vec![
"📖 *Available Commands*".to_string(),
String::new(),
"`/compact` — Compact context (summarize & trim)".to_string(),
"`/evolve` — Download latest release & restart".to_string(),
"`/help` — Show this message".to_string(),
"`/models` — Switch AI model".to_string(),
"`/new` — Start a new session".to_string(),
"`/sessions` — Switch between sessions".to_string(),
"`/stop` — Abort current operation".to_string(),
"`/usage` — Session token & cost stats".to_string(),
];
let brain_path = crate::brain::BrainLoader::resolve_path();
let loader = crate::brain::CommandLoader::from_brain_path(&brain_path);
let mut user_cmds = loader.load();
if !user_cmds.is_empty() {
user_cmds.sort_by(|a, b| a.name.cmp(&b.name));
lines.push(String::new());
lines.push("📌 *Custom Commands*".to_string());
for cmd in &user_cmds {
lines.push(format!("`{}` — {}", cmd.name, cmd.description));
}
}
lines.push(String::new());
lines.push("🦀 Any other message is sent to OpenCrabs. 🦀".to_string());
lines.join("\n")
}
async fn format_usage(
session_id: Uuid,
agent: &AgentService,
session_svc: &SessionService,
) -> String {
use crate::usage::data::{DashboardData, Period, fmt_cost, fmt_tokens};
let mut lines = vec!["📊 *Usage Dashboard*".to_string(), String::new()];
let current_model = agent.provider_model();
match session_svc.get_session(session_id).await {
Ok(Some(session)) => {
let name = session.title.as_deref().unwrap_or("Current Session");
let model = session
.model
.as_deref()
.filter(|m| !m.is_empty())
.unwrap_or(¤t_model);
let tokens = session.token_count;
let cost = if session.total_cost > 0.0 {
session.total_cost
} else if tokens > 0 {
estimate_cost(model, tokens as i64).unwrap_or(0.0)
} else {
0.0
};
lines.push(format!("*Current:* {}", name));
lines.push(format!(
" `{}` · {} tok · ${:.4}",
model,
format_number(tokens as i64),
cost
));
}
_ => {
lines.push("*Current:* (session not found)".to_string());
}
}
lines.push(String::new());
for period in [Period::Today, Period::AllTime] {
let pool = session_svc.pool();
let data = match DashboardData::fetch(&pool, period).await {
Ok(d) => d,
Err(e) => {
lines.push(format!("*{}:* (failed to load: {})", period.label(), e));
lines.push(String::new());
continue;
}
};
lines.push(format!("━━ *{}* ━━", period.label()));
lines.push(format!(
"{} tok · {} · {} sessions · {} calls",
fmt_tokens(data.summary.total_tokens),
fmt_cost(data.summary.total_cost),
format_number(data.summary.session_count),
format_number(data.summary.call_count),
));
if !data.daily.is_empty() && period != Period::Today {
lines.push(String::new());
lines.push("*Daily:*".to_string());
let window: Vec<_> = data.daily.iter().rev().take(7).collect();
for d in window.iter().rev() {
lines.push(format!(
" {} · {} tok · {}",
d.date,
fmt_tokens(d.tokens),
fmt_cost(d.cost)
));
}
}
if !data.models.is_empty() {
lines.push(String::new());
lines.push("*By Model:*".to_string());
for m in data.models.iter().take(5) {
let est = if m.estimated { " ~" } else { "" };
lines.push(format!(
" `{}` · {} tok · {}{}",
m.model,
fmt_tokens(m.tokens),
fmt_cost(m.cost),
est
));
}
}
if !data.tools.is_empty() {
lines.push(String::new());
lines.push("*Core Tools:*".to_string());
for t in data.tools.iter().take(5) {
lines.push(format!(
" `{}` · {} calls",
t.tool_name,
format_number(t.call_count)
));
}
}
if !data.projects.is_empty() {
lines.push(String::new());
lines.push("*By Project:*".to_string());
for p in data.projects.iter().take(5) {
lines.push(format!(
" `{}` · {} · {} sessions",
p.project,
fmt_cost(p.cost),
p.sessions
));
}
}
if !data.activities.is_empty() {
lines.push(String::new());
lines.push("*By Activity:*".to_string());
for a in data.activities.iter().take(5) {
lines.push(format!(
" {} · {} · {} turns · {:.0}% one-shot",
a.category,
fmt_cost(a.cost),
a.turns,
a.one_shot_pct * 100.0,
));
}
}
lines.push(String::new());
}
lines.join("\n")
}
fn estimate_cost(model: &str, token_count: i64) -> Option<f64> {
crate::usage::pricing::PricingConfig::load()
.ok()
.and_then(|cfg| cfg.estimate_cost(model, token_count))
}
fn format_number(n: i64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
async fn format_sessions(
current_session_id: Uuid,
session_svc: &SessionService,
) -> SessionsResponse {
let sessions = session_svc
.list_sessions(SessionListOptions {
include_archived: false,
limit: Some(10),
offset: 0,
})
.await
.unwrap_or_default();
let mut text_lines = vec!["📂 *Sessions*".to_string(), String::new()];
let mut items = Vec::new();
for s in &sessions {
let title = s.title.as_deref().unwrap_or("Untitled");
let marker = if s.id == current_session_id {
" ✓"
} else {
""
};
let date = s.updated_at.format("%b %d %H:%M");
let label = format!("{} ({})", title, date);
text_lines.push(format!("• `{}`{}", label, marker));
items.push((s.id, label));
}
if sessions.is_empty() {
text_lines.push("No sessions found.".to_string());
}
SessionsResponse {
current_session_id,
sessions: items,
text: text_lines.join("\n"),
}
}
fn format_providers(agent: &AgentService) -> ProvidersResponse {
let current_provider = agent.provider_name();
let current_model = agent.provider_model();
let providers = configured_providers();
let mut text_lines = vec![
"🤖 *Switch Provider*".to_string(),
format!("Current: `{}` / `{}`", current_provider, current_model),
String::new(),
];
for (name, label) in &providers {
let marker = if *name == current_provider {
" ✓"
} else {
""
};
text_lines.push(format!("• `{}`{}", label, marker));
}
ProvidersResponse {
current_provider: current_provider.clone(),
current_model: current_model.clone(),
providers,
text: text_lines.join("\n"),
}
}
fn configured_providers() -> Vec<(String, String)> {
let config = match crate::config::Config::load() {
Ok(c) => c,
Err(_) => return vec![],
};
crate::utils::providers::configured_providers(&config.providers)
}
pub async fn models_for_provider(provider_name: &str) -> ModelsResponse {
let config = match crate::config::Config::load() {
Ok(c) => c,
Err(_) => {
return ModelsResponse {
provider_name: provider_name.to_string(),
current_model: String::new(),
models: vec![],
text: "Failed to load config.".to_string(),
agent_handled: false,
};
}
};
let display_name = provider_display_name(provider_name);
let config_models = provider_config_models(&config, provider_name);
let is_cli_provider = matches!(
provider_name,
"claude-cli" | "claude_cli" | "opencode-cli" | "opencode_cli"
);
if is_cli_provider {
let current_model = config_models.first().cloned().unwrap_or_else(|| {
if provider_name.starts_with("claude") {
"opus-4-7".to_string()
} else {
"sonnet-4.5".to_string()
}
});
let models = if !config_models.is_empty() {
config_models
} else if provider_name.starts_with("claude") {
vec![
"opus-4-7".to_string(),
"sonnet-4-6".to_string(),
"haiku-4-5".to_string(),
]
} else {
vec!["sonnet-4.5".to_string(), "opus-4.1".to_string()]
};
let mut text_lines = vec![
format!("🤖 *{} Models*", display_name),
format!("Current: `{}`", current_model),
String::new(),
];
for (i, m) in models.iter().enumerate() {
let marker = if *m == current_model { " ✓" } else { "" };
text_lines.push(format!("{}. `{}`{}", i + 1, m, marker));
}
return ModelsResponse {
provider_name: provider_name.to_string(),
current_model,
models,
text: text_lines.join("\n"),
agent_handled: false,
};
}
if provider_name == "openrouter" || provider_name.starts_with("custom:") {
let config_default = crate::utils::providers::config_for(&config.providers, provider_name)
.and_then(|c| c.default_model.clone());
let current_model = config_models
.first()
.cloned()
.or(config_default)
.unwrap_or_else(|| {
if provider_name.starts_with("custom:") {
"unknown (no models configured)".to_string()
} else {
"openrouter-default".to_string()
}
});
let models = if !config_models.is_empty() {
config_models
} else {
vec![current_model.clone()]
};
let mut text_lines = vec![
format!("🤖 *{} Models*", display_name),
format!("Current: `{}`", current_model),
String::new(),
];
for (i, m) in models.iter().enumerate() {
let marker = if *m == current_model { " ✓" } else { "" };
text_lines.push(format!("{}. `{}`{}", i + 1, m, marker));
}
return ModelsResponse {
provider_name: provider_name.to_string(),
current_model,
models,
text: text_lines.join("\n"),
agent_handled: false,
};
}
let provider = match crate::brain::provider::factory::create_provider_by_name(
&config,
provider_name,
)
.await
{
Ok(p) => p,
Err(e) => {
return ModelsResponse {
provider_name: provider_name.to_string(),
current_model: String::new(),
models: vec![],
text: format!("Failed to create provider: {}", e),
agent_handled: false,
};
}
};
let current_model = provider.default_model().to_string();
let mut models = if !config_models.is_empty() {
config_models
} else {
match tokio::time::timeout(std::time::Duration::from_secs(10), provider.fetch_models())
.await
{
Ok(fetched) if !fetched.is_empty() => fetched,
Ok(_) => vec![current_model.clone()],
Err(_) => {
tracing::warn!("fetch_models timed out for '{}'", provider_name);
vec![current_model.clone()]
}
}
};
if !models.contains(¤t_model) {
models.insert(0, current_model.clone());
}
let mut text_lines = vec![
format!("🤖 *{} Models*", display_name),
format!("Current: `{}`", current_model),
String::new(),
];
for (i, m) in models.iter().enumerate() {
let marker = if *m == current_model { " ✓" } else { "" };
text_lines.push(format!("{}. `{}`{}", i + 1, m, marker));
}
ModelsResponse {
provider_name: provider_name.to_string(),
current_model,
models,
text: text_lines.join("\n"),
agent_handled: false,
}
}
fn provider_config_models(config: &crate::config::Config, name: &str) -> Vec<String> {
crate::utils::providers::config_for(&config.providers, name)
.map(|c| c.models.clone())
.unwrap_or_default()
}
pub fn provider_display_name(name: &str) -> &str {
crate::utils::providers::display_name(name)
}
pub async fn switch_model(
agent: &AgentService,
model_name: &str,
session_id: Option<uuid::Uuid>,
provider_name_override: Option<&str>,
) -> Result<String, String> {
let provider_name = match provider_name_override {
Some(p) => p.to_string(),
None => match session_id {
Some(sid) => agent.provider_name_for_session(sid),
None => agent.provider_name(),
},
};
let config =
crate::config::Config::load().map_err(|e| format!("Failed to load config: {}", e))?;
tracing::info!(
"Channel: switched model to {} (provider: {}, session: {:?})",
model_name,
provider_name,
session_id
);
let new_provider =
crate::brain::provider::factory::create_provider_by_name(&config, &provider_name)
.await
.map_err(|e| {
tracing::warn!("Failed to create provider after model switch: {}", e);
format!("Model saved but failed to reload provider: {}", e)
})?;
let display_name = provider_display_name(&provider_name);
match session_id {
Some(sid) => agent.swap_provider_for_session(sid, new_provider),
None => agent.swap_provider(new_provider),
}
let change_msg = format!("[Model changed to {}/{}]", display_name, model_name);
if let Some(sid) = session_id {
let session_svc = crate::services::SessionService::new(agent.context().clone());
if let Ok(Some(mut session)) = session_svc.get_session(sid).await {
session.provider_name = Some(provider_name.clone());
session.model = Some(model_name.to_string());
if let Err(e) = session_svc.update_session(&session).await {
tracing::warn!("Failed to persist provider to session: {}", e);
}
}
let msg_svc = crate::services::MessageService::new(agent.context().clone());
if let Err(e) = msg_svc
.create_message(sid, "user".to_string(), change_msg.clone())
.await
{
tracing::warn!("Failed to persist model-change message: {}", e);
}
}
Ok(change_msg)
}
pub async fn run_evolve() -> String {
use crate::brain::tools::{Tool, ToolExecutionContext, evolve::EvolveTool};
let ctx = ToolExecutionContext::new(uuid::Uuid::nil());
let tool = EvolveTool::new(None);
match tool
.execute(serde_json::json!({"check_only": false}), &ctx)
.await
{
Ok(result) => result.output,
Err(e) => format!("Evolve failed: {}", e),
}
}
pub fn run_doctor() -> String {
use crate::brain::tools::slash_command::SlashCommandTool;
SlashCommandTool::doctor_text()
}
pub async fn try_execute_text_command(cmd: &ChannelCommand) -> Option<String> {
match cmd {
ChannelCommand::Help(body)
| ChannelCommand::Usage(body)
| ChannelCommand::UserSystem(body) => Some(body.clone()),
ChannelCommand::Doctor => Some(run_doctor()),
ChannelCommand::Evolve => Some(run_evolve().await),
_ => None,
}
}
#[cfg(test)]
pub(crate) fn provider_section(provider_name: &str) -> Option<String> {
crate::utils::providers::config_section(provider_name)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::brain::commands::UserCommand;
#[test]
fn format_number_small() {
assert_eq!(format_number(0), "0");
assert_eq!(format_number(1), "1");
assert_eq!(format_number(999), "999");
}
#[test]
fn format_number_thousands() {
assert_eq!(format_number(1_000), "1.0K");
assert_eq!(format_number(1_500), "1.5K");
assert_eq!(format_number(999_999), "1000.0K");
}
#[test]
fn format_number_millions() {
assert_eq!(format_number(1_000_000), "1.0M");
assert_eq!(format_number(2_500_000), "2.5M");
assert_eq!(format_number(123_456_789), "123.5M");
}
#[test]
fn format_help_contains_all_commands() {
let help = format_help();
for cmd in [
"/evolve",
"/help",
"/models",
"/new",
"/sessions",
"/stop",
"/usage",
] {
assert!(help.contains(cmd), "help text missing {}", cmd);
}
}
#[test]
fn format_help_is_alphabetical() {
let help = format_help();
let builtin_section = help.split("Custom Commands").next().unwrap_or(&help);
let commands: Vec<&str> = builtin_section
.lines()
.filter_map(|line| {
let trimmed = line.trim().strip_prefix('`')?;
let cmd = trimmed.split('`').next()?;
if cmd.starts_with('/') {
Some(cmd.split_whitespace().next().unwrap_or(cmd))
} else {
None
}
})
.collect();
let mut sorted = commands.clone();
sorted.sort();
assert_eq!(
commands, sorted,
"built-in help commands are not alphabetical"
);
}
#[test]
fn provider_display_name_known() {
assert_eq!(provider_display_name("anthropic"), "Anthropic");
assert_eq!(provider_display_name("openai"), "OpenAI");
assert_eq!(provider_display_name("github"), "GitHub Copilot");
assert_eq!(provider_display_name("openrouter"), "OpenRouter");
assert_eq!(provider_display_name("minimax"), "MiniMax");
assert_eq!(provider_display_name("gemini"), "Gemini");
}
#[test]
fn provider_aliases_normalize_and_match() {
assert_eq!(normalize_provider_name("GitHub Copilot"), "github");
assert_eq!(normalize_provider_name("Google Gemini"), "gemini");
assert_eq!(
normalize_provider_name("custom(DeepSeek)"),
"custom:deepseek"
);
assert!(provider_names_match("custom:deepseek", "deepseek"));
}
#[test]
fn provider_display_name_custom() {
assert_eq!(provider_display_name("custom:deepseek"), "deepseek");
assert_eq!(provider_display_name("custom:local-llm"), "local-llm");
}
#[test]
fn provider_display_name_unknown() {
assert_eq!(provider_display_name("mystery"), "mystery");
}
fn make_cmd(name: &str, action: &str, prompt: &str) -> UserCommand {
UserCommand {
name: name.to_string(),
description: String::new(),
action: action.to_string(),
prompt: prompt.to_string(),
}
}
#[test]
fn user_command_prompt_no_args() {
let cmds = vec![make_cmd(
"/credits",
"prompt",
"Check my OpenRouter credits",
)];
match match_user_command_inner("/credits", &cmds, &[]) {
ChannelCommand::UserPrompt(p) => {
assert_eq!(p, "Check my OpenRouter credits");
}
other => panic!("expected UserPrompt, got {:?}", variant_name(&other)),
}
}
#[test]
fn user_command_prompt_with_args() {
let cmds = vec![make_cmd("/deploy", "prompt", "Deploy the service")];
match match_user_command_inner("/deploy staging --dry-run", &cmds, &[]) {
ChannelCommand::UserPrompt(p) => {
assert_eq!(p, "Deploy the service staging --dry-run");
}
other => panic!("expected UserPrompt, got {:?}", variant_name(&other)),
}
}
#[test]
fn user_command_system_action() {
let cmds = vec![make_cmd("/info", "system", "OpenCrabs v0.2")];
match match_user_command_inner("/info", &cmds, &[]) {
ChannelCommand::UserSystem(t) => assert_eq!(t, "OpenCrabs v0.2"),
other => panic!("expected UserSystem, got {:?}", variant_name(&other)),
}
}
#[test]
fn user_command_unknown_falls_through() {
let cmds = vec![make_cmd("/credits", "prompt", "Check credits")];
assert!(matches!(
match_user_command_inner("/unknown", &cmds, &[]),
ChannelCommand::NotACommand
));
}
#[test]
fn user_command_empty_list() {
assert!(matches!(
match_user_command_inner("/anything", &[], &[]),
ChannelCommand::NotACommand
));
}
#[test]
fn user_command_default_action_is_prompt() {
let cmds = vec![make_cmd("/test", "whatever", "test prompt")];
assert!(matches!(
match_user_command_inner("/test", &cmds, &[]),
ChannelCommand::UserPrompt(_)
));
}
fn variant_name(cmd: &ChannelCommand) -> &'static str {
match cmd {
ChannelCommand::Compact => "Compact",
ChannelCommand::Help(_) => "Help",
ChannelCommand::Usage(_) => "Usage",
ChannelCommand::Models(_) => "Models",
ChannelCommand::NewSession => "NewSession",
ChannelCommand::Sessions(_) => "Sessions",
ChannelCommand::Stop => "Stop",
ChannelCommand::UserPrompt(_) => "UserPrompt",
ChannelCommand::UserSystem(_) => "UserSystem",
ChannelCommand::Doctor => "Doctor",
ChannelCommand::Evolve => "Evolve",
ChannelCommand::NotACommand => "NotACommand",
}
}
}