#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::types::Message;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PromptInputMode {
#[default]
Prompt,
Bash,
Print,
Continue,
}
#[derive(Debug, Clone)]
pub struct ProcessUserInputContext {
pub session_id: String,
pub cwd: String,
pub agent_id: Option<String>,
pub query_tracking: Option<QueryTracking>,
pub options: ProcessUserInputContextOptions,
pub loaded_nested_memory_paths: std::collections::HashSet<String>,
pub discovered_skill_names: std::collections::HashSet<String>,
pub dynamic_skill_dir_triggers: std::collections::HashSet<String>,
pub nested_memory_attachment_triggers: std::collections::HashSet<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QueryTracking {
pub chain_id: String,
pub depth: u32,
}
#[derive(Debug, Clone)]
pub struct ProcessUserInputContextOptions {
pub commands: Vec<Value>,
pub debug: bool,
pub tools: Vec<crate::types::ToolDefinition>,
pub verbose: bool,
pub main_loop_model: Option<String>,
pub thinking_config: Option<crate::types::api_types::ThinkingConfig>,
pub mcp_clients: Vec<Value>,
pub mcp_resources: std::collections::HashMap<String, Value>,
pub ide_installation_status: Option<Value>,
pub is_non_interactive_session: bool,
pub custom_system_prompt: Option<String>,
pub append_system_prompt: Option<String>,
pub agent_definitions: AgentDefinitions,
pub theme: Option<String>,
pub max_budget_usd: Option<f64>,
}
impl Default for ProcessUserInputContext {
fn default() -> Self {
Self {
session_id: String::new(),
cwd: String::new(),
agent_id: None,
query_tracking: None,
options: ProcessUserInputContextOptions::default(),
loaded_nested_memory_paths: std::collections::HashSet::new(),
discovered_skill_names: std::collections::HashSet::new(),
dynamic_skill_dir_triggers: std::collections::HashSet::new(),
nested_memory_attachment_triggers: std::collections::HashSet::new(),
}
}
}
impl Default for ProcessUserInputContextOptions {
fn default() -> Self {
Self {
commands: vec![],
debug: false,
tools: vec![],
verbose: false,
main_loop_model: None,
thinking_config: None,
mcp_clients: vec![],
mcp_resources: std::collections::HashMap::new(),
ide_installation_status: None,
is_non_interactive_session: false,
custom_system_prompt: None,
append_system_prompt: None,
agent_definitions: AgentDefinitions::default(),
theme: None,
max_budget_usd: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentDefinitions {
pub active_agents: Vec<Value>,
pub all_agents: Vec<Value>,
pub allowed_agent_types: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EffortValue {
pub effort: String,
pub reason: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ProcessUserInputBaseResult {
pub messages: Vec<Message>,
pub should_query: bool,
pub allowed_tools: Option<Vec<String>>,
pub model: Option<String>,
pub effort: Option<EffortValue>,
pub result_text: Option<String>,
pub next_input: Option<String>,
pub submit_next_input: Option<bool>,
}
impl Default for ProcessUserInputBaseResult {
fn default() -> Self {
Self {
messages: vec![],
should_query: true,
allowed_tools: None,
model: None,
effort: None,
result_text: None,
next_input: None,
submit_next_input: None,
}
}
}
pub struct ProcessUserInputOptions {
pub input: ProcessUserInput,
pub pre_expansion_input: Option<String>,
pub mode: PromptInputMode,
pub context: ProcessUserInputContext,
pub pasted_contents: Option<std::collections::HashMap<u32, PastedContent>>,
pub ide_selection: Option<IdeSelection>,
pub messages: Option<Vec<Message>>,
pub set_user_input_on_processing: Option<Box<dyn Fn(Option<String>) + Send + Sync>>,
pub uuid: Option<String>,
pub is_already_processing: Option<bool>,
pub query_source: Option<QuerySource>,
pub can_use_tool: Option<crate::utils::hooks::CanUseToolFnJson>,
pub skip_slash_commands: Option<bool>,
pub bridge_origin: Option<bool>,
pub is_meta: Option<bool>,
pub skip_attachments: Option<bool>,
}
impl Default for ProcessUserInputOptions {
fn default() -> Self {
Self {
input: ProcessUserInput::String(String::new()),
pre_expansion_input: None,
mode: PromptInputMode::Prompt,
context: ProcessUserInputContext::default(),
pasted_contents: None,
ide_selection: None,
messages: None,
set_user_input_on_processing: None,
uuid: None,
is_already_processing: None,
query_source: None,
can_use_tool: None,
skip_slash_commands: None,
bridge_origin: None,
is_meta: None,
skip_attachments: None,
}
}
}
#[derive(Clone)]
pub enum ProcessUserInput {
String(String),
ContentBlocks(Vec<ContentBlockParam>),
}
impl std::fmt::Debug for ProcessUserInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProcessUserInput::String(s) => f.debug_tuple("String").field(s).finish(),
ProcessUserInput::ContentBlocks(blocks) => {
f.debug_tuple("ContentBlocks").field(blocks).finish()
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ContentBlockParam {
Text {
text: String,
},
Image {
source: ImageSource,
},
ToolUse {
id: String,
name: String,
input: Value,
},
ToolResult {
tool_use_id: String,
content: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageSource {
#[serde(rename = "type")]
pub source_type: String,
pub media_type: String,
pub data: String,
}
#[derive(Debug, Clone)]
pub struct PastedContent {
pub id: u32,
pub content: String,
pub media_type: Option<String>,
pub source_path: Option<String>,
pub dimensions: Option<ImageDimensions>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageDimensions {
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IdeSelection {
pub file_path: String,
pub selected_text: Option<String>,
pub cursor_position: Option<CursorPosition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CursorPosition {
pub line: u32,
pub character: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum QuerySource {
Prompt,
Continue,
SlashCommand,
BashCommand,
Attachments,
AutoAttach,
Resubmit,
}
pub async fn process_user_input(
options: ProcessUserInputOptions,
) -> Result<ProcessUserInputBaseResult, String> {
let input_string = match &options.input {
ProcessUserInput::String(s) => Some(s.clone()),
ProcessUserInput::ContentBlocks(blocks) => blocks.iter().find_map(|b| {
if let ContentBlockParam::Text { text } = b {
Some(text.clone())
} else {
None
}
}),
};
if options.mode == PromptInputMode::Prompt
&& input_string.is_some()
&& options.is_meta != Some(true)
{
if let Some(ref callback) = options.set_user_input_on_processing {
callback(input_string.clone());
}
}
let input = options.input;
let mode = options.mode;
let context = options.context;
let pasted_contents = options.pasted_contents;
let uuid = options.uuid;
let is_meta = options.is_meta;
let skip_slash_commands = options.skip_slash_commands;
let bridge_origin = options.bridge_origin;
let result = process_user_input_base(
input,
mode,
context,
pasted_contents,
uuid,
is_meta,
skip_slash_commands,
bridge_origin,
)
.await?;
Ok(result)
}
async fn process_user_input_base(
input: ProcessUserInput,
mode: PromptInputMode,
context: ProcessUserInputContext,
pasted_contents: Option<std::collections::HashMap<u32, PastedContent>>,
uuid: Option<String>,
is_meta: Option<bool>,
skip_slash_commands: Option<bool>,
bridge_origin: Option<bool>,
) -> Result<ProcessUserInputBaseResult, String> {
let input_string = match &input {
ProcessUserInput::String(s) => Some(s.clone()),
ProcessUserInput::ContentBlocks(blocks) => blocks.iter().find_map(|b| {
if let ContentBlockParam::Text { text } = b {
Some(text.clone())
} else {
None
}
}),
};
let mut preceding_input_blocks: Vec<ContentBlockParam> = vec![];
let mut normalized_input = input.clone();
if let ProcessUserInput::ContentBlocks(blocks) = &input {
if !blocks.is_empty() {
let last_block = blocks.last().unwrap();
if let ContentBlockParam::Text { text } = last_block {
let text = text.clone();
preceding_input_blocks = blocks[..blocks.len() - 1].to_vec();
normalized_input = ProcessUserInput::String(text);
} else {
preceding_input_blocks = blocks.clone();
}
}
}
if input_string.is_none() && mode != PromptInputMode::Prompt {
return Err(format!("Mode: {:?} requires a string input.", mode));
}
let image_content_blocks = process_pasted_images(pasted_contents.as_ref()).await;
let effective_skip_slash = check_bridge_safe_slash_command(
bridge_origin,
input_string.as_deref(),
skip_slash_commands,
);
if let Some(input) = input_string {
if mode == PromptInputMode::Bash {
return process_bash_command(input, preceding_input_blocks, vec![], &context).await;
}
if !effective_skip_slash && input.starts_with('/') {
return process_slash_command(
input,
preceding_input_blocks,
image_content_blocks,
vec![],
&context,
).await;
}
}
process_text_prompt(
normalized_input,
image_content_blocks,
vec![],
uuid,
None, is_meta,
)
}
fn check_bridge_safe_slash_command(
bridge_origin: Option<bool>,
input_string: Option<&str>,
skip_slash_commands: Option<bool>,
) -> bool {
if bridge_origin != Some(true) {
return skip_slash_commands.unwrap_or(false);
}
let input = match input_string {
Some(s) => s,
None => return skip_slash_commands.unwrap_or(false),
};
if !input.starts_with('/') {
return skip_slash_commands.unwrap_or(false);
}
false
}
async fn process_pasted_images(
pasted_contents: Option<&std::collections::HashMap<u32, PastedContent>>,
) -> Vec<ContentBlockParam> {
if pasted_contents.is_none() {
return vec![];
}
let contents = pasted_contents.unwrap();
let mut image_blocks = vec![];
for (_, pasted) in contents.iter() {
let media_type = pasted.media_type.as_deref().unwrap_or("image/png");
image_blocks.push(ContentBlockParam::Image {
source: ImageSource {
source_type: "base64".to_string(),
media_type: media_type.to_string(),
data: pasted.content.clone(),
},
});
}
image_blocks
}
fn process_text_prompt(
input: ProcessUserInput,
_image_content_blocks: Vec<ContentBlockParam>,
_attachment_messages: Vec<Message>,
uuid: Option<String>,
_permission_mode: Option<crate::types::api_types::PermissionMode>,
is_meta: Option<bool>,
) -> Result<ProcessUserInputBaseResult, String> {
let content = match input {
ProcessUserInput::String(s) => {
if s.trim().is_empty() {
vec![]
} else {
vec![Value::String(s)]
}
}
ProcessUserInput::ContentBlocks(blocks) => blocks
.iter()
.map(|b| serde_json::to_value(b).unwrap_or(Value::Null))
.collect(),
};
let message = Message {
role: crate::types::MessageRole::User,
content: serde_json::json!({ "type": "text", "text": content }).to_string(),
attachments: None,
tool_call_id: None,
tool_calls: None,
is_api_error_message: None,
error_details: None,
is_error: None,
is_meta: None,
uuid: None,
};
Ok(ProcessUserInputBaseResult {
messages: vec![message],
should_query: true,
..Default::default()
})
}
fn format_command_input_tags(command_name: &str, args: &str) -> String {
let mut parts = vec![
format!("<command-message>{}</command-message>", command_name),
format!("<command-name>/{}</command-name>", command_name),
];
if !args.trim().is_empty() {
parts.push(format!("<command-args>{}</command-args>", args));
}
parts.join("\n")
}
struct ParsedSlashCommand {
command_name: String,
args: String,
is_mcp: bool,
}
fn parse_slash_command(input: &str) -> Option<ParsedSlashCommand> {
let trimmed = input.trim();
if !trimmed.starts_with('/') || trimmed.len() <= 1 {
return None;
}
let without_slash = &trimmed[1..];
let words: Vec<&str> = without_slash.split_whitespace().collect();
if words.is_empty() {
return None;
}
let mut command_name = words[0].to_string();
let mut is_mcp = false;
let mut args_start = 1;
if words.len() > 1 && words[1] == "(MCP)" {
command_name = format!("{} (MCP)", command_name);
is_mcp = true;
args_start = 2;
}
let args = if args_start < words.len() {
let skip_len = 1 + words[0].len(); let skip_len = if is_mcp {
skip_len + 1 + 5 } else {
skip_len + 1 };
let skipped = trimmed.chars().skip(skip_len).collect::<String>();
skipped.trim_start().to_string()
} else {
String::new()
};
Some(ParsedSlashCommand {
command_name,
args,
is_mcp,
})
}
fn looks_like_command(command_name: &str) -> bool {
command_name.chars().all(|c| {
c.is_alphanumeric() || c == ':' || c == '-' || c == '_'
})
}
fn find_command(name: &str, commands: &[serde_json::Value]) -> Option<serde_json::Value> {
for cmd in commands {
let cmd_name = cmd.get("name").and_then(|n| n.as_str()).unwrap_or("");
if cmd_name == name {
return Some(cmd.clone());
}
if let Some(aliases) = cmd.get("aliases").and_then(|a| a.as_array()) {
for alias in aliases {
if let Some(a) = alias.as_str() {
if a == name {
return Some(cmd.clone());
}
}
}
}
}
None
}
fn has_command(name: &str, commands: &[serde_json::Value]) -> bool {
find_command(name, commands).is_some()
}
fn make_user_message(content: String, is_meta: Option<bool>) -> Message {
Message {
role: crate::types::MessageRole::User,
content,
uuid: Some(uuid::Uuid::new_v4().to_string()),
attachments: None,
tool_call_id: None,
tool_calls: None,
is_error: None,
is_meta,
is_api_error_message: None,
error_details: None,
}
}
fn make_system_message(content: String) -> Message {
Message {
role: crate::types::MessageRole::System,
content,
uuid: Some(uuid::Uuid::new_v4().to_string()),
attachments: None,
tool_call_id: None,
tool_calls: None,
is_error: None,
is_meta: None,
is_api_error_message: None,
error_details: None,
}
}
fn make_synthetic_caveat() -> Message {
Message {
role: crate::types::MessageRole::User,
content: "The user didn't say anything. Continue working.".to_string(),
uuid: Some(uuid::Uuid::new_v4().to_string()),
attachments: None,
tool_call_id: None,
tool_calls: None,
is_error: None,
is_meta: Some(true),
is_api_error_message: None,
error_details: None,
}
}
fn make_system_local_command(content: String) -> Message {
make_system_message(content)
}
async fn process_bash_command(
input: String,
_preceding_input_blocks: Vec<ContentBlockParam>,
attachment_messages: Vec<Message>,
context: &ProcessUserInputContext,
) -> Result<ProcessUserInputBaseResult, String> {
let user_message = make_user_message(
format!("<bash-input>{}</bash-input>", input),
None,
);
let use_powershell = crate::tools::config_tools::is_powershell_tool_enabled();
let tool_result = if use_powershell {
let ps_tool = crate::tools::powershell::PowerShellTool::new();
ps_tool
.execute(
serde_json::json!({"command": input}),
&crate::types::ToolContext {
cwd: context.cwd.clone(),
abort_signal: Default::default(),
},
)
.await
} else {
let bash_tool = crate::tools::bash::BashTool::new();
bash_tool
.execute(
serde_json::json!({"command": input}),
&crate::types::ToolContext {
cwd: context.cwd.clone(),
abort_signal: Default::default(),
},
)
.await
};
let escape_xml = crate::utils::xml::escape_xml;
match tool_result {
Ok(result) => {
let stdout = if result.content.is_empty() {
"".to_string()
} else {
result.content.clone()
};
let stderr = if result.is_error == Some(true) {
"Command completed with errors".to_string()
} else {
String::new()
};
let output_message = make_user_message(
format!(
"<bash-stdout>{}</bash-stdout><bash-stderr>{}</bash-stderr>",
escape_xml(&stdout),
escape_xml(&stderr)
),
None,
);
let mut messages = vec![
make_synthetic_caveat(),
user_message,
];
messages.extend(attachment_messages);
messages.push(output_message);
Ok(ProcessUserInputBaseResult {
messages,
should_query: false,
..Default::default()
})
}
Err(e) => {
let error_message = make_user_message(
format!(
"<bash-stderr>Command failed: {}</bash-stderr>",
escape_xml(&e.to_string())
),
None,
);
let mut messages = vec![
make_synthetic_caveat(),
user_message,
];
messages.extend(attachment_messages);
messages.push(error_message);
Ok(ProcessUserInputBaseResult {
messages,
should_query: false,
..Default::default()
})
}
}
}
async fn process_slash_command(
input: String,
preceding_input_blocks: Vec<ContentBlockParam>,
_image_content_blocks: Vec<ContentBlockParam>,
attachment_messages: Vec<Message>,
context: &ProcessUserInputContext,
) -> Result<ProcessUserInputBaseResult, String> {
let parsed = parse_slash_command(&input);
let parsed = match parsed {
Some(p) => p,
None => {
let error_msg = "Commands are in the form `/command [args]`".to_string();
return Ok(ProcessUserInputBaseResult {
messages: vec![
make_synthetic_caveat(),
]
.into_iter()
.chain(attachment_messages.into_iter())
.chain(std::iter::once(make_user_message(error_msg.clone(), None)))
.collect(),
should_query: false,
result_text: Some(error_msg),
..Default::default()
});
}
};
let ParsedSlashCommand {
command_name,
args,
is_mcp: _is_mcp,
} = parsed;
if !has_command(&command_name, &context.options.commands) {
let fs = std::path::Path::new(&command_name);
let is_file_path = fs.exists();
if looks_like_command(&command_name) && !is_file_path {
let unknown_msg = format!("Unknown skill: {}", command_name);
let mut messages = vec![
make_synthetic_caveat(),
];
messages.extend(attachment_messages);
messages.push(make_user_message(unknown_msg.clone(), None));
if !args.trim().is_empty() {
messages.push(make_system_message(
format!("Args from unknown skill: {}", args)
));
}
return Ok(ProcessUserInputBaseResult {
messages,
should_query: false,
result_text: Some(unknown_msg),
..Default::default()
});
}
let content = if preceding_input_blocks.is_empty() {
input
} else {
format!("[{} blocks] {}", preceding_input_blocks.len(), input)
};
return Ok(ProcessUserInputBaseResult {
messages: vec![make_user_message(content, None)]
.into_iter()
.chain(attachment_messages)
.collect(),
should_query: true,
..Default::default()
});
}
let command = find_command(&command_name, &context.options.commands)
.ok_or_else(|| format!("Command '{}' not found", command_name))?;
let command_type = command.get("type").and_then(|t| t.as_str()).unwrap_or("");
match command_type {
"local" => execute_local_command(command_name, args, command, preceding_input_blocks, attachment_messages, context).await,
"prompt" => execute_prompt_command(command_name, args, command, preceding_input_blocks, attachment_messages, context).await,
"local-jsx" => {
let msg = format!("Command '/{}' requires a UI and is not available in this environment.", command_name);
Ok(ProcessUserInputBaseResult {
messages: vec![
make_synthetic_caveat(),
make_user_message(msg.clone(), None),
]
.into_iter()
.chain(attachment_messages)
.collect(),
should_query: false,
result_text: Some(msg),
..Default::default()
})
}
_ => {
let msg = format!("Unknown command type: {}", command_type);
Err(msg)
}
}
}
async fn execute_local_command(
command_name: String,
args: String,
_command: serde_json::Value,
_preceding_input_blocks: Vec<ContentBlockParam>,
attachment_messages: Vec<Message>,
_context: &ProcessUserInputContext,
) -> Result<ProcessUserInputBaseResult, String> {
let input_display = format_command_input_tags(&command_name, &args);
let user_message = make_user_message(input_display, None);
let result = dispatch_local_command(&command_name, &args).await;
match result {
Ok(call_result) => {
use crate::commands::version::CommandCallResult;
match call_result.result_type.as_str() {
"text" => {
let output = if call_result.value.is_empty() {
make_system_local_command(
"(no output)".to_string()
)
} else {
make_system_local_command(
format!("<local-command-stdout>{}</local-command-stdout>", call_result.value)
)
};
let mut messages = vec![
make_synthetic_caveat(),
user_message,
];
messages.extend(attachment_messages);
messages.push(output);
Ok(ProcessUserInputBaseResult {
messages,
should_query: false,
result_text: Some(call_result.value),
..Default::default()
})
}
"compact" => {
let mut messages = vec![
make_synthetic_caveat(),
user_message,
];
messages.extend(attachment_messages);
messages.push(make_system_local_command(
format!("<local-command-stdout>Conversation compacted</local-command-stdout>")
));
Ok(ProcessUserInputBaseResult {
messages,
should_query: false,
result_text: Some("Conversation compacted".to_string()),
..Default::default()
})
}
"skip" => Ok(ProcessUserInputBaseResult {
messages: vec![],
should_query: false,
..Default::default()
}),
_ => Err(format!("Unknown local command result type: {}", call_result.result_type)),
}
}
Err(e) => {
let mut messages = vec![
make_synthetic_caveat(),
user_message,
];
messages.extend(attachment_messages);
messages.push(make_system_local_command(
format!("<local-command-stderr>{}</local-command-stderr>", e)
));
Ok(ProcessUserInputBaseResult {
messages,
should_query: false,
..Default::default()
})
}
}
}
async fn dispatch_local_command(
name: &str,
args: &str,
) -> Result<crate::commands::version::CommandCallResult, String> {
match name {
"clear" => handle_clear_command(args),
"cost" => handle_cost_command(args),
"compact" => handle_compact_command(args),
"version" => handle_version_command(args),
"model" => handle_model_command(args),
_ => {
Ok(crate::commands::version::CommandCallResult::text(
format!("Command '/{}' is registered but not yet implemented in this environment.", name)
))
}
}
}
fn handle_clear_command(args: &str) -> Result<crate::commands::version::CommandCallResult, String> {
let target = args.trim().split_whitespace().next().unwrap_or("conversation");
match target {
"cache" => Ok(crate::commands::version::CommandCallResult::text(
"Cache cleared."
)),
"all" => Ok(crate::commands::version::CommandCallResult::text(
"Conversation and cache cleared."
)),
_ => Ok(crate::commands::version::CommandCallResult::text("")),
}
}
fn handle_cost_command(_args: &str) -> Result<crate::commands::version::CommandCallResult, String> {
Ok(crate::commands::version::CommandCallResult::text(
"Cost tracking is available through the session's cost tracker."
))
}
fn handle_compact_command(_args: &str) -> Result<crate::commands::version::CommandCallResult, String> {
Ok(crate::commands::version::CommandCallResult {
result_type: "compact".to_string(),
value: "Compact requested".to_string(),
})
}
fn handle_version_command(_args: &str) -> Result<crate::commands::version::CommandCallResult, String> {
let version = env!("CARGO_PKG_VERSION");
Ok(crate::commands::version::CommandCallResult::text(version))
}
fn handle_model_command(_args: &str) -> Result<crate::commands::version::CommandCallResult, String> {
Ok(crate::commands::version::CommandCallResult::text(
"Model configuration is managed through session settings."
))
}
async fn execute_prompt_command(
command_name: String,
args: String,
command: serde_json::Value,
preceding_input_blocks: Vec<ContentBlockParam>,
attachment_messages: Vec<Message>,
_context: &ProcessUserInputContext,
) -> Result<ProcessUserInputBaseResult, String> {
let input_display = format_command_input_tags(&command_name, &args);
let progress_message = command.get("progressMessage").and_then(|p| p.as_str()).unwrap_or("Loading");
let model = command.get("model").and_then(|m| m.as_str()).map(String::from);
let allowed_tools = command
.get("allowedTools")
.and_then(|t| t.as_array())
.map(|t| t.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<String>>());
let effort = command.get("effort").and_then(|e| e.as_str()).map(|e| {
crate::utils::process_user_input::EffortValue {
effort: e.to_string(),
reason: None,
}
});
let content = command.get("content").and_then(|c| c.as_str()).unwrap_or("");
let prompt_text = if !args.trim().is_empty() {
format!("{}\n\nArguments: {}", content, args)
} else {
content.to_string()
};
let full_content = if preceding_input_blocks.is_empty() {
prompt_text
} else {
format!("[{} preceding blocks]\n{}", preceding_input_blocks.len(), prompt_text)
};
let mut messages = vec![
make_system_message(
format!("[{}] {}", progress_message, command_name)
),
make_user_message(input_display, None),
];
messages.extend(attachment_messages);
messages.push(make_user_message(full_content, None));
Ok(ProcessUserInputBaseResult {
messages,
should_query: true,
allowed_tools,
model,
effort,
..Default::default()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_process_user_input_default() {
let options = ProcessUserInputOptions::default();
assert!(matches!(options.input, ProcessUserInput::String(s) if s.is_empty()));
assert_eq!(options.mode, PromptInputMode::Prompt);
}
#[test]
fn test_process_text_prompt() {
let result = process_text_prompt(
ProcessUserInput::String("Hello".to_string()),
vec![],
vec![],
Some("test-uuid".to_string()),
None,
Some(true),
)
.unwrap();
assert!(result.should_query);
assert_eq!(result.messages.len(), 1);
}
#[test]
fn test_parse_slash_command_basic() {
let parsed = parse_slash_command("/compact").unwrap();
assert_eq!(parsed.command_name, "compact");
assert_eq!(parsed.args, "");
assert!(!parsed.is_mcp);
}
#[test]
fn test_parse_slash_command_with_args() {
let parsed = parse_slash_command("/model opus").unwrap();
assert_eq!(parsed.command_name, "model");
assert_eq!(parsed.args, "opus");
assert!(!parsed.is_mcp);
}
#[test]
fn test_parse_slash_command_mcp() {
let parsed = parse_slash_command("/my-tool (MCP) arg1 arg2").unwrap();
assert_eq!(parsed.command_name, "my-tool (MCP)");
assert_eq!(parsed.args, "arg1 arg2");
assert!(parsed.is_mcp);
}
#[test]
fn test_parse_slash_command_no_slash() {
assert!(parse_slash_command("hello").is_none());
}
#[test]
fn test_parse_slash_command_empty() {
assert!(parse_slash_command("/").is_none());
}
#[test]
fn test_parse_slash_command_spaces_only() {
assert!(parse_slash_command("/ ").is_none());
}
#[test]
fn test_looks_like_command_valid() {
assert!(looks_like_command("compact"));
assert!(looks_like_command("my-command"));
assert!(looks_like_command("my_command"));
assert!(looks_like_command("my:command"));
assert!(looks_like_command("cmd123"));
}
#[test]
fn test_looks_like_command_invalid() {
assert!(!looks_like_command("/var/log"));
assert!(!looks_like_command("file.txt"));
assert!(!looks_like_command("path/to/file"));
}
#[test]
fn test_has_command() {
let commands = vec![
serde_json::json!({"name": "clear", "type": "local"}),
serde_json::json!({"name": "compact", "type": "local", "aliases": ["summarize"]}),
];
assert!(has_command("clear", &commands));
assert!(has_command("compact", &commands));
assert!(has_command("summarize", &commands));
assert!(!has_command("unknown", &commands));
}
#[test]
fn test_find_command_by_name() {
let commands = vec![
serde_json::json!({"name": "clear", "type": "local"}),
];
let cmd = find_command("clear", &commands).unwrap();
assert_eq!(cmd["name"], "clear");
}
#[test]
fn test_find_command_by_alias() {
let commands = vec![
serde_json::json!({"name": "compact", "aliases": ["summarize"]}),
];
let cmd = find_command("summarize", &commands).unwrap();
assert_eq!(cmd["name"], "compact");
}
#[test]
fn test_format_command_input_tags() {
let tags = format_command_input_tags("compact", "");
assert!(tags.contains("<command-message>compact</command-message>"));
assert!(tags.contains("<command-name>/compact</command-name>"));
assert!(!tags.contains("<command-args>"));
}
#[test]
fn test_format_command_input_tags_with_args() {
let tags = format_command_input_tags("model", "opus");
assert!(tags.contains("<command-message>model</command-message>"));
assert!(tags.contains("<command-name>/model</command-name>"));
assert!(tags.contains("<command-args>opus</command-args>"));
}
#[tokio::test]
async fn test_dispatch_clear_command() {
let result = dispatch_local_command("clear", "").await.unwrap();
assert_eq!(result.result_type, "text");
assert_eq!(result.value, "");
}
#[tokio::test]
async fn test_dispatch_clear_cache_command() {
let result = dispatch_local_command("clear", "cache").await.unwrap();
assert_eq!(result.result_type, "text");
assert!(result.value.contains("Cache cleared"));
}
#[tokio::test]
async fn test_dispatch_version_command() {
let result = dispatch_local_command("version", "").await.unwrap();
assert_eq!(result.result_type, "text");
assert!(!result.value.is_empty());
}
#[tokio::test]
async fn test_dispatch_unknown_command() {
let result = dispatch_local_command("unknown-cmd", "").await.unwrap();
assert_eq!(result.result_type, "text");
assert!(result.value.contains("not yet implemented"));
}
#[tokio::test]
async fn test_dispatch_compact_command() {
let result = dispatch_local_command("compact", "").await.unwrap();
assert_eq!(result.result_type, "compact");
}
#[tokio::test]
async fn test_process_slash_command_invalid() {
let context = ProcessUserInputContext::default();
let result = process_slash_command(
"hello".to_string(),
vec![],
vec![],
vec![],
&context,
).await;
assert!(result.is_ok());
let r = result.unwrap();
assert!(!r.should_query);
assert!(r.result_text.as_ref().unwrap().contains("Commands are in the form"));
}
#[tokio::test]
async fn test_process_slash_command_unknown() {
let context = ProcessUserInputContext::default();
let result = process_slash_command(
"/nonexistent-command".to_string(),
vec![],
vec![],
vec![],
&context,
).await;
assert!(result.is_ok());
let r = result.unwrap();
assert!(!r.should_query);
assert!(r.result_text.as_ref().unwrap().contains("Unknown skill"));
}
#[tokio::test]
async fn test_process_slash_command_known_local() {
let mut commands = vec![];
commands.push(serde_json::json!({"name": "clear", "type": "local"}));
let context = ProcessUserInputContext {
options: ProcessUserInputContextOptions {
commands,
..Default::default()
},
..Default::default()
};
let result = process_slash_command(
"/clear".to_string(),
vec![],
vec![],
vec![],
&context,
).await;
assert!(result.is_ok());
let r = result.unwrap();
assert!(!r.should_query);
}
#[tokio::test]
async fn test_process_bash_command_echo() {
let context = ProcessUserInputContext::default();
let result = process_bash_command(
"echo hello".to_string(),
vec![],
vec![],
&context,
).await;
assert!(result.is_ok());
let r = result.unwrap();
assert!(!r.should_query);
assert!(!r.messages.is_empty());
}
}