use crate::channels::ChannelProvider;
use crate::configuration::AgentConfig;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case", tag = "kind", content = "name")]
pub enum SystemCommand {
Compact,
Usage,
Clear,
Help,
Custom(String),
}
impl SystemCommand {
pub fn from_name(name: &str) -> Option<Self> {
match name {
"/compact" => Some(Self::Compact),
"/usage" => Some(Self::Usage),
"/clear" => Some(Self::Clear),
"/help" => Some(Self::Help),
_ => None,
}
}
pub fn from_name_with_custom(name: &str) -> Self {
Self::from_name(name).unwrap_or_else(|| {
let stripped = name.strip_prefix('/').unwrap_or(name).to_string();
Self::Custom(stripped)
})
}
pub fn name(&self) -> String {
match self {
Self::Compact => "/compact".to_string(),
Self::Usage => "/usage".to_string(),
Self::Clear => "/clear".to_string(),
Self::Help => "/help".to_string(),
Self::Custom(n) => {
if n.starts_with('/') {
n.clone()
} else {
format!("/{n}")
}
}
}
}
pub fn description(&self) -> &'static str {
match self {
Self::Compact => "Summarize prior turns to free context",
Self::Usage => "Show current context usage",
Self::Clear => "Start a new thread, keep the same agent",
Self::Help => "List available commands",
Self::Custom(_) => "",
}
}
pub fn builtins() -> [SystemCommand; 4] {
[Self::Compact, Self::Usage, Self::Clear, Self::Help]
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, JsonSchema)]
pub struct SlashCommand {
pub name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<ChannelProvider>,
pub prompt: String,
}
pub fn system_command_as_slash(cmd: &SystemCommand) -> SlashCommand {
SlashCommand {
name: cmd.name(),
description: cmd.description().to_string(),
aliases: vec![],
channels: vec![],
prompt: String::new(),
}
}
pub fn system_commands() -> Vec<SlashCommand> {
SystemCommand::builtins()
.iter()
.map(system_command_as_slash)
.collect()
}
pub fn resolve_commands(agent: &AgentConfig) -> Vec<SlashCommand> {
resolve_commands_with(agent, &[])
}
pub fn resolve_commands_with(agent: &AgentConfig, extra: &[SlashCommand]) -> Vec<SlashCommand> {
let mut out = system_commands();
if !extra.is_empty() {
let extra_names: std::collections::HashSet<String> =
extra.iter().map(|c| c.name.clone()).collect();
out.retain(|c| !extra_names.contains(&c.name));
out.extend(extra.iter().cloned());
}
let agent_cmds: Vec<SlashCommand> = match agent {
AgentConfig::StandardAgent(d) => d.commands.clone(),
AgentConfig::WorkflowAgent(_) => Vec::new(),
};
if !agent_cmds.is_empty() {
let declared: std::collections::HashSet<String> =
agent_cmds.iter().map(|c| c.name.clone()).collect();
out.retain(|c| !declared.contains(&c.name));
out.extend(agent_cmds);
}
out
}
pub fn is_system_command(name: &str) -> bool {
SystemCommand::from_name(name).is_some()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ReplyButtonSpec {
Url {
label: String,
url: String,
},
WebApp {
label: String,
url: String,
},
Callback {
label: String,
callback_data: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ChannelButton {
Url {
label: String,
url: String,
},
WebApp {
label: String,
url: String,
},
Callback {
label: String,
callback_data: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChannelReply {
pub text: String,
#[serde(default)]
pub buttons: Vec<Vec<ChannelButton>>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ToSchema, JsonSchema)]
pub struct ChannelBindings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub telegram: Option<TelegramBinding>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ToSchema, JsonSchema)]
pub struct TelegramBinding {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub menu_button: Option<MenuButton>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web_app_base: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, JsonSchema)]
pub struct MenuButton {
pub label: String,
pub url: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reply_button_spec_kinds_round_trip() {
for json in [
serde_json::json!({"kind":"url","label":"Docs","url":"https://d.dev"}),
serde_json::json!({"kind":"web_app","label":"Open","url":"https://a.app"}),
serde_json::json!({"kind":"callback","label":"Pick","callback_data":"wf:x"}),
] {
let b: ReplyButtonSpec = serde_json::from_value(json.clone()).unwrap();
assert_eq!(serde_json::to_value(&b).unwrap(), json);
}
}
#[test]
fn channel_bindings_defaults_are_none() {
let b: ChannelBindings = serde_json::from_value(serde_json::json!({})).unwrap();
assert!(b.telegram.is_none());
}
#[test]
fn system_command_round_trips_builtins() {
for cmd in SystemCommand::builtins() {
let resolved = SystemCommand::from_name(&cmd.name()).unwrap();
assert_eq!(resolved, cmd);
assert!(!cmd.description().is_empty());
}
}
#[test]
fn system_command_falls_through_to_custom() {
let cmd = SystemCommand::from_name_with_custom("/workspace");
assert_eq!(cmd, SystemCommand::Custom("workspace".to_string()));
assert_eq!(
SystemCommand::from_name_with_custom("/compact"),
SystemCommand::Compact
);
assert_eq!(SystemCommand::Custom("ws".into()).name(), "/ws");
}
#[test]
fn resolve_commands_with_layers_extras() {
let cfg: AgentConfig = serde_json::from_value(serde_json::json!({
"agent_type": "standard_agent",
"name": "s",
"description": "d",
}))
.unwrap();
let extra = vec![SlashCommand {
name: "/workspace".into(),
description: "Open workspace switcher".into(),
aliases: vec![],
channels: vec![],
prompt: String::new(),
}];
let resolved = resolve_commands_with(&cfg, &extra);
let names: Vec<&str> = resolved.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"/compact"));
assert!(names.contains(&"/workspace"));
}
#[test]
fn resolve_commands_with_extras_shadow_builtins() {
let cfg: AgentConfig = serde_json::from_value(serde_json::json!({
"agent_type": "standard_agent",
"name": "s",
"description": "d",
}))
.unwrap();
let extra = vec![SlashCommand {
name: "/help".into(),
description: "Cloud help".into(),
aliases: vec![],
channels: vec![],
prompt: String::new(),
}];
let resolved = resolve_commands_with(&cfg, &extra);
let help: Vec<&SlashCommand> = resolved.iter().filter(|c| c.name == "/help").collect();
assert_eq!(help.len(), 1);
assert_eq!(help[0].description, "Cloud help");
}
#[test]
fn system_commands_present_for_standard_agent_without_commands() {
let cfg: AgentConfig = serde_json::from_value(serde_json::json!({
"agent_type": "standard_agent",
"name": "s",
"description": "d",
}))
.unwrap();
let resolved = resolve_commands(&cfg);
let names: Vec<&str> = resolved.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"/compact"));
assert!(names.contains(&"/usage"));
assert!(names.contains(&"/clear"));
assert!(names.contains(&"/help"));
}
#[test]
fn agent_command_shadows_system_command() {
let cfg: AgentConfig = serde_json::from_value(serde_json::json!({
"agent_type": "standard_agent",
"name": "s",
"description": "d",
"commands": [
{"name":"/compact","description":"override","prompt":"do it"}
]
}))
.unwrap();
let resolved = resolve_commands(&cfg);
let compact: Vec<&SlashCommand> =
resolved.iter().filter(|c| c.name == "/compact").collect();
assert_eq!(compact.len(), 1);
assert_eq!(compact[0].prompt, "do it");
}
#[test]
fn workflow_agent_still_gets_system_commands() {
let cfg: AgentConfig = serde_json::from_value(serde_json::json!({
"agent_type": "workflow_agent",
"name": "w",
"description": "d",
"definition": {}
}))
.unwrap();
let resolved = resolve_commands(&cfg);
assert!(resolved.iter().any(|c| c.name == "/compact"));
}
#[test]
fn telegram_menu_button_round_trips() {
let json = serde_json::json!({
"telegram": {
"menu_button": {"label":"Open","url":"https://a.app/learn"},
"web_app_base": "https://a.app"
}
});
let b: ChannelBindings = serde_json::from_value(json.clone()).unwrap();
assert_eq!(
b.telegram
.as_ref()
.unwrap()
.menu_button
.as_ref()
.unwrap()
.label,
"Open"
);
assert_eq!(serde_json::to_value(&b).unwrap(), json);
}
#[test]
fn channel_reply_holds_resolved_buttons() {
let r = ChannelReply {
text: "Your classes:".into(),
buttons: vec![vec![ChannelButton::Callback {
label: "Math".into(),
callback_data: "wf:open_class:m1".into(),
}]],
};
let v = serde_json::to_value(&r).unwrap();
let back: ChannelReply = serde_json::from_value(v).unwrap();
assert_eq!(back.text, "Your classes:");
assert_eq!(back.buttons[0].len(), 1);
}
}