Skip to main content

ai_agent/utils/
process_user_input.rs

1//! Process user input utilities - translates processUserInput.ts from TypeScript
2//!
3//! This module handles processing user input, including text prompts, bash commands,
4//! slash commands, and attachments.
5
6#![allow(dead_code)]
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::types::Message;
12
13/// Prompt input mode
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
15#[serde(rename_all = "lowercase")]
16pub enum PromptInputMode {
17    #[default]
18    Prompt,
19    Bash,
20    Print,
21    Continue,
22}
23
24/// Process user input context - combines ToolUseContext and LocalJSXCommandContext
25/// (Extended to match TypeScript's rich context with memory/skill tracking)
26#[derive(Debug, Clone)]
27pub struct ProcessUserInputContext {
28    /// Session ID
29    pub session_id: String,
30    /// Current working directory
31    pub cwd: String,
32    /// Agent ID if set
33    pub agent_id: Option<String>,
34    /// Query tracking information
35    pub query_tracking: Option<QueryTracking>,
36    /// Context options
37    pub options: ProcessUserInputContextOptions,
38    /// Track nested memory paths loaded via memory attachment triggers
39    pub loaded_nested_memory_paths: std::collections::HashSet<String>,
40    /// Track discovered skill names (feeds was_discovered on skill_tool_invocation)
41    pub discovered_skill_names: std::collections::HashSet<String>,
42    /// Trigger directories for dynamic skill loading
43    pub dynamic_skill_dir_triggers: std::collections::HashSet<String>,
44    /// Trigger paths for nested memory attachments
45    pub nested_memory_attachment_triggers: std::collections::HashSet<String>,
46}
47
48/// Query tracking for analytics
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct QueryTracking {
52    pub chain_id: String,
53    pub depth: u32,
54}
55
56/// Process user input context options
57#[derive(Debug, Clone)]
58pub struct ProcessUserInputContextOptions {
59    /// Available commands
60    pub commands: Vec<Value>,
61    /// Debug mode
62    pub debug: bool,
63    /// Available tools
64    pub tools: Vec<crate::types::ToolDefinition>,
65    /// Verbose mode
66    pub verbose: bool,
67    /// Main loop model
68    pub main_loop_model: Option<String>,
69    /// Thinking configuration
70    pub thinking_config: Option<crate::types::api_types::ThinkingConfig>,
71    /// MCP clients
72    pub mcp_clients: Vec<Value>,
73    /// MCP resources
74    pub mcp_resources: std::collections::HashMap<String, Value>,
75    /// IDE installation status
76    pub ide_installation_status: Option<Value>,
77    /// Non-interactive session flag
78    pub is_non_interactive_session: bool,
79    /// Custom system prompt
80    pub custom_system_prompt: Option<String>,
81    /// Append system prompt
82    pub append_system_prompt: Option<String>,
83    /// Agent definitions
84    pub agent_definitions: AgentDefinitions,
85    /// Theme
86    pub theme: Option<String>,
87    /// Max budget in USD
88    pub max_budget_usd: Option<f64>,
89}
90
91impl Default for ProcessUserInputContext {
92    fn default() -> Self {
93        Self {
94            session_id: String::new(),
95            cwd: String::new(),
96            agent_id: None,
97            query_tracking: None,
98            options: ProcessUserInputContextOptions::default(),
99            loaded_nested_memory_paths: std::collections::HashSet::new(),
100            discovered_skill_names: std::collections::HashSet::new(),
101            dynamic_skill_dir_triggers: std::collections::HashSet::new(),
102            nested_memory_attachment_triggers: std::collections::HashSet::new(),
103        }
104    }
105}
106
107impl Default for ProcessUserInputContextOptions {
108    fn default() -> Self {
109        Self {
110            commands: vec![],
111            debug: false,
112            tools: vec![],
113            verbose: false,
114            main_loop_model: None,
115            thinking_config: None,
116            mcp_clients: vec![],
117            mcp_resources: std::collections::HashMap::new(),
118            ide_installation_status: None,
119            is_non_interactive_session: false,
120            custom_system_prompt: None,
121            append_system_prompt: None,
122            agent_definitions: AgentDefinitions::default(),
123            theme: None,
124            max_budget_usd: None,
125        }
126    }
127}
128
129/// Agent definitions
130#[derive(Debug, Clone, Default, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct AgentDefinitions {
133    pub active_agents: Vec<Value>,
134    pub all_agents: Vec<Value>,
135    pub allowed_agent_types: Option<Vec<String>>,
136}
137
138/// Effort value for the model
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct EffortValue {
142    pub effort: String,
143    pub reason: Option<String>,
144}
145
146/// Result of processing user input
147#[derive(Debug, Clone)]
148pub struct ProcessUserInputBaseResult {
149    /// Messages to be sent to the model
150    pub messages: Vec<Message>,
151    /// Whether a query should be made
152    pub should_query: bool,
153    /// Allowed tools (optional)
154    pub allowed_tools: Option<Vec<String>>,
155    /// Model to use (optional)
156    pub model: Option<String>,
157    /// Effort value (optional)
158    pub effort: Option<EffortValue>,
159    /// Output text for non-interactive mode
160    pub result_text: Option<String>,
161    /// Next input to prefilling (optional)
162    pub next_input: Option<String>,
163    /// Whether to submit next input
164    pub submit_next_input: Option<bool>,
165}
166
167impl Default for ProcessUserInputBaseResult {
168    fn default() -> Self {
169        Self {
170            messages: vec![],
171            should_query: true,
172            allowed_tools: None,
173            model: None,
174            effort: None,
175            result_text: None,
176            next_input: None,
177            submit_next_input: None,
178        }
179    }
180}
181
182/// Input for process_user_input function
183pub struct ProcessUserInputOptions {
184    /// Input string or content blocks
185    pub input: ProcessUserInput,
186    /// Input before expansion (for ultraplan keyword detection)
187    pub pre_expansion_input: Option<String>,
188    /// Input mode
189    pub mode: PromptInputMode,
190    /// Context for processing
191    pub context: ProcessUserInputContext,
192    /// Pasted contents from the user
193    pub pasted_contents: Option<std::collections::HashMap<u32, PastedContent>>,
194    /// IDE selection
195    pub ide_selection: Option<IdeSelection>,
196    /// Existing messages
197    pub messages: Option<Vec<Message>>,
198    /// Function to set user input while processing
199    pub set_user_input_on_processing: Option<Box<dyn Fn(Option<String>) + Send + Sync>>,
200    /// UUID for the prompt
201    pub uuid: Option<String>,
202    /// Whether input is already being processed
203    pub is_already_processing: Option<bool>,
204    /// Query source
205    pub query_source: Option<QuerySource>,
206    /// Function to check if tool can be used
207    pub can_use_tool: Option<crate::utils::hooks::CanUseToolFnJson>,
208    /// Skip slash command processing
209    pub skip_slash_commands: Option<bool>,
210    /// Bridge origin (for remote control)
211    pub bridge_origin: Option<bool>,
212    /// Whether this is a meta message (system-generated)
213    pub is_meta: Option<bool>,
214    /// Skip attachment processing
215    pub skip_attachments: Option<bool>,
216}
217
218impl Default for ProcessUserInputOptions {
219    fn default() -> Self {
220        Self {
221            input: ProcessUserInput::String(String::new()),
222            pre_expansion_input: None,
223            mode: PromptInputMode::Prompt,
224            context: ProcessUserInputContext::default(),
225            pasted_contents: None,
226            ide_selection: None,
227            messages: None,
228            set_user_input_on_processing: None,
229            uuid: None,
230            is_already_processing: None,
231            query_source: None,
232            can_use_tool: None,
233            skip_slash_commands: None,
234            bridge_origin: None,
235            is_meta: None,
236            skip_attachments: None,
237        }
238    }
239}
240
241/// User input - either string or content blocks
242#[derive(Clone)]
243pub enum ProcessUserInput {
244    String(String),
245    ContentBlocks(Vec<ContentBlockParam>),
246}
247
248impl std::fmt::Debug for ProcessUserInput {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        match self {
251            ProcessUserInput::String(s) => f.debug_tuple("String").field(s).finish(),
252            ProcessUserInput::ContentBlocks(blocks) => {
253                f.debug_tuple("ContentBlocks").field(blocks).finish()
254            }
255        }
256    }
257}
258
259/// Content block parameter (similar to Anthropic SDK's ContentBlockParam)
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase")]
262pub enum ContentBlockParam {
263    /// Text content block
264    Text {
265        /// Text content
266        text: String,
267    },
268    /// Image content block
269    Image {
270        /// Image source
271        source: ImageSource,
272    },
273    /// Tool use content block
274    ToolUse {
275        /// Tool use ID
276        id: String,
277        /// Tool name
278        name: String,
279        /// Tool input
280        input: Value,
281    },
282    /// Tool result content block
283    ToolResult {
284        /// Tool use ID
285        tool_use_id: String,
286        /// Tool result content
287        content: Value,
288        /// Whether this is an error
289        #[serde(default, skip_serializing_if = "Option::is_none")]
290        is_error: Option<bool>,
291    },
292}
293
294/// Image source for content blocks
295#[derive(Debug, Clone, Serialize, Deserialize)]
296#[serde(rename_all = "camelCase")]
297pub struct ImageSource {
298    /// Image type (base64)
299    #[serde(rename = "type")]
300    pub source_type: String,
301    /// Media type (e.g., "image/png")
302    pub media_type: String,
303    /// Base64-encoded image data
304    pub data: String,
305}
306
307/// Pasted content from user
308#[derive(Debug, Clone)]
309pub struct PastedContent {
310    /// Unique ID
311    pub id: u32,
312    /// Content (base64-encoded)
313    pub content: String,
314    /// Media type
315    pub media_type: Option<String>,
316    /// Source path (optional)
317    pub source_path: Option<String>,
318    /// Dimensions (optional)
319    pub dimensions: Option<ImageDimensions>,
320}
321
322/// Image dimensions
323#[derive(Debug, Clone, Serialize, Deserialize)]
324#[serde(rename_all = "camelCase")]
325pub struct ImageDimensions {
326    pub width: u32,
327    pub height: u32,
328}
329
330/// IDE selection
331#[derive(Debug, Clone, Serialize, Deserialize)]
332#[serde(rename_all = "camelCase")]
333pub struct IdeSelection {
334    /// File path
335    pub file_path: String,
336    /// Selected text
337    pub selected_text: Option<String>,
338    /// Cursor position
339    pub cursor_position: Option<CursorPosition>,
340}
341
342/// Cursor position in IDE
343#[derive(Debug, Clone, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase")]
345pub struct CursorPosition {
346    pub line: u32,
347    pub character: u32,
348}
349
350/// Query source enum
351#[derive(Debug, Clone, Serialize, Deserialize)]
352#[serde(rename_all = "snake_case")]
353pub enum QuerySource {
354    Prompt,
355    Continue,
356    SlashCommand,
357    BashCommand,
358    Attachments,
359    AutoAttach,
360    Resubmit,
361}
362
363/// Process user input - main entry point
364///
365/// # Arguments
366/// * `options` - Options for processing user input
367///
368/// # Returns
369/// A future that resolves to ProcessUserInputBaseResult
370pub async fn process_user_input(
371    options: ProcessUserInputOptions,
372) -> Result<ProcessUserInputBaseResult, String> {
373    let input_string = match &options.input {
374        ProcessUserInput::String(s) => Some(s.clone()),
375        ProcessUserInput::ContentBlocks(blocks) => blocks.iter().find_map(|b| {
376            if let ContentBlockParam::Text { text } = b {
377                Some(text.clone())
378            } else {
379                None
380            }
381        }),
382    };
383
384    // Set user input on processing if in prompt mode
385    if options.mode == PromptInputMode::Prompt
386        && input_string.is_some()
387        && options.is_meta != Some(true)
388    {
389        if let Some(ref callback) = options.set_user_input_on_processing {
390            callback(input_string.clone());
391        }
392    }
393
394    // Process the input - take ownership of needed fields
395    let input = options.input;
396    let mode = options.mode;
397    let context = options.context;
398    let pasted_contents = options.pasted_contents;
399    let uuid = options.uuid;
400    let is_meta = options.is_meta;
401    let skip_slash_commands = options.skip_slash_commands;
402    let bridge_origin = options.bridge_origin;
403
404    let result = process_user_input_base(
405        input,
406        mode,
407        context,
408        pasted_contents,
409        uuid,
410        is_meta,
411        skip_slash_commands,
412        bridge_origin,
413    )
414    .await?;
415
416    // Execute user prompt submit hooks (simplified stub)
417    // In the full implementation, this would execute hooks and potentially modify result
418
419    Ok(result)
420}
421
422/// Internal function to process user input
423async fn process_user_input_base(
424    input: ProcessUserInput,
425    mode: PromptInputMode,
426    context: ProcessUserInputContext,
427    pasted_contents: Option<std::collections::HashMap<u32, PastedContent>>,
428    uuid: Option<String>,
429    is_meta: Option<bool>,
430    skip_slash_commands: Option<bool>,
431    bridge_origin: Option<bool>,
432) -> Result<ProcessUserInputBaseResult, String> {
433    let input_string = match &input {
434        ProcessUserInput::String(s) => Some(s.clone()),
435        ProcessUserInput::ContentBlocks(blocks) => blocks.iter().find_map(|b| {
436            if let ContentBlockParam::Text { text } = b {
437                Some(text.clone())
438            } else {
439                None
440            }
441        }),
442    };
443
444    let mut preceding_input_blocks: Vec<ContentBlockParam> = vec![];
445    let mut normalized_input = input.clone();
446
447    // Handle content blocks - extract text and preceding blocks
448    if let ProcessUserInput::ContentBlocks(blocks) = &input {
449        if !blocks.is_empty() {
450            let last_block = blocks.last().unwrap();
451            if let ContentBlockParam::Text { text } = last_block {
452                let text = text.clone();
453                preceding_input_blocks = blocks[..blocks.len() - 1].to_vec();
454                normalized_input = ProcessUserInput::String(text);
455            } else {
456                preceding_input_blocks = blocks.clone();
457            }
458        }
459    }
460
461    // Validate mode requires string input
462    if input_string.is_none() && mode != PromptInputMode::Prompt {
463        return Err(format!("Mode: {:?} requires a string input.", mode));
464    }
465
466    // Process pasted images
467    let image_content_blocks = process_pasted_images(pasted_contents.as_ref()).await;
468
469    // Check for bridge-safe slash command override
470    let effective_skip_slash = check_bridge_safe_slash_command(
471        bridge_origin,
472        input_string.as_deref(),
473        skip_slash_commands,
474    );
475
476    // Handle bash commands
477    if let Some(input) = input_string {
478        if mode == PromptInputMode::Bash {
479            return process_bash_command(input, preceding_input_blocks, vec![], &context).await;
480        }
481
482        // Handle slash commands
483        if !effective_skip_slash && input.starts_with('/') {
484            return process_slash_command(
485                input,
486                preceding_input_blocks,
487                image_content_blocks,
488                vec![],
489                &context,
490            ).await;
491        }
492    }
493
494    // Regular user prompt
495    process_text_prompt(
496        normalized_input,
497        image_content_blocks,
498        vec![],
499        uuid,
500        None, // permission_mode
501        is_meta,
502    )
503}
504
505/// Check if slash commands should be skipped for bridge origin
506fn check_bridge_safe_slash_command(
507    bridge_origin: Option<bool>,
508    input_string: Option<&str>,
509    skip_slash_commands: Option<bool>,
510) -> bool {
511    if bridge_origin != Some(true) {
512        return skip_slash_commands.unwrap_or(false);
513    }
514
515    let input = match input_string {
516        Some(s) => s,
517        None => return skip_slash_commands.unwrap_or(false),
518    };
519
520    if !input.starts_with('/') {
521        return skip_slash_commands.unwrap_or(false);
522    }
523
524    // For bridge origin with slash command, we don't skip
525    false
526}
527
528/// Process pasted images
529async fn process_pasted_images(
530    pasted_contents: Option<&std::collections::HashMap<u32, PastedContent>>,
531) -> Vec<ContentBlockParam> {
532    if pasted_contents.is_none() {
533        return vec![];
534    }
535
536    let contents = pasted_contents.unwrap();
537    let mut image_blocks = vec![];
538
539    for (_, pasted) in contents.iter() {
540        let media_type = pasted.media_type.as_deref().unwrap_or("image/png");
541        image_blocks.push(ContentBlockParam::Image {
542            source: ImageSource {
543                source_type: "base64".to_string(),
544                media_type: media_type.to_string(),
545                data: pasted.content.clone(),
546            },
547        });
548    }
549
550    image_blocks
551}
552
553/// Process text prompt
554fn process_text_prompt(
555    input: ProcessUserInput,
556    _image_content_blocks: Vec<ContentBlockParam>,
557    _attachment_messages: Vec<Message>,
558    uuid: Option<String>,
559    _permission_mode: Option<crate::types::api_types::PermissionMode>,
560    is_meta: Option<bool>,
561) -> Result<ProcessUserInputBaseResult, String> {
562    let content = match input {
563        ProcessUserInput::String(s) => {
564            if s.trim().is_empty() {
565                vec![]
566            } else {
567                vec![Value::String(s)]
568            }
569        }
570        ProcessUserInput::ContentBlocks(blocks) => blocks
571            .iter()
572            .map(|b| serde_json::to_value(b).unwrap_or(Value::Null))
573            .collect(),
574    };
575
576    let message = Message {
577        role: crate::types::MessageRole::User,
578        content: serde_json::json!({ "type": "text", "text": content }).to_string(),
579        attachments: None,
580        tool_call_id: None,
581        tool_calls: None,
582        is_api_error_message: None,
583        error_details: None,
584        is_error: None,
585        is_meta: None,
586            uuid: None,
587    };
588
589    Ok(ProcessUserInputBaseResult {
590        messages: vec![message],
591        should_query: true,
592        ..Default::default()
593    })
594}
595
596/// Format command input with XML tags
597fn format_command_input_tags(command_name: &str, args: &str) -> String {
598    let mut parts = vec![
599        format!("<command-message>{}</command-message>", command_name),
600        format!("<command-name>/{}</command-name>", command_name),
601    ];
602    if !args.trim().is_empty() {
603        parts.push(format!("<command-args>{}</command-args>", args));
604    }
605    parts.join("\n")
606}
607
608/// Parsed slash command result
609struct ParsedSlashCommand {
610    command_name: String,
611    args: String,
612    is_mcp: bool,
613}
614
615/// Parses a slash command input string into its component parts.
616/// Returns null if input doesn't start with '/' or is empty after stripping.
617fn parse_slash_command(input: &str) -> Option<ParsedSlashCommand> {
618    let trimmed = input.trim();
619    if !trimmed.starts_with('/') || trimmed.len() <= 1 {
620        return None;
621    }
622    let without_slash = &trimmed[1..];
623    let words: Vec<&str> = without_slash.split_whitespace().collect();
624    if words.is_empty() {
625        return None;
626    }
627    let mut command_name = words[0].to_string();
628    let mut is_mcp = false;
629    let mut args_start = 1;
630
631    // Check for MCP commands (second word is '(MCP)')
632    if words.len() > 1 && words[1] == "(MCP)" {
633        command_name = format!("{} (MCP)", command_name);
634        is_mcp = true;
635        args_start = 2;
636    }
637
638    // Reconstruct args from original string to preserve spacing
639    let args = if args_start < words.len() {
640        // Find position after command name + (MCP) in the original string
641        let skip_len = 1 + words[0].len(); // '/' + command
642        let skip_len = if is_mcp {
643            skip_len + 1 + 5 // space + "(MCP)"
644        } else {
645            skip_len + 1 // space
646        };
647        let skipped = trimmed.chars().skip(skip_len).collect::<String>();
648        skipped.trim_start().to_string()
649    } else {
650        String::new()
651    };
652
653    Some(ParsedSlashCommand {
654        command_name,
655        args,
656        is_mcp,
657    })
658}
659
660/// Determines if a string looks like a valid command name.
661/// Valid command names only contain letters, numbers, colons, hyphens, and underscores.
662fn looks_like_command(command_name: &str) -> bool {
663    command_name.chars().all(|c| {
664        c.is_alphanumeric() || c == ':' || c == '-' || c == '_'
665    })
666}
667
668/// Find a command by name or alias in the available commands.
669fn find_command(name: &str, commands: &[serde_json::Value]) -> Option<serde_json::Value> {
670    for cmd in commands {
671        let cmd_name = cmd.get("name").and_then(|n| n.as_str()).unwrap_or("");
672        if cmd_name == name {
673            return Some(cmd.clone());
674        }
675        // Check aliases
676        if let Some(aliases) = cmd.get("aliases").and_then(|a| a.as_array()) {
677            for alias in aliases {
678                if let Some(a) = alias.as_str() {
679                    if a == name {
680                        return Some(cmd.clone());
681                    }
682                }
683            }
684        }
685    }
686    None
687}
688
689/// Check if a command name exists in the available commands.
690fn has_command(name: &str, commands: &[serde_json::Value]) -> bool {
691    find_command(name, commands).is_some()
692}
693
694/// Create a user message with string content
695fn make_user_message(content: String, is_meta: Option<bool>) -> Message {
696    Message {
697        role: crate::types::MessageRole::User,
698        content,
699        uuid: Some(uuid::Uuid::new_v4().to_string()),
700        attachments: None,
701        tool_call_id: None,
702        tool_calls: None,
703        is_error: None,
704        is_meta,
705        is_api_error_message: None,
706        error_details: None,
707    }
708}
709
710/// Create a system message
711fn make_system_message(content: String) -> Message {
712    Message {
713        role: crate::types::MessageRole::System,
714        content,
715        uuid: Some(uuid::Uuid::new_v4().to_string()),
716        attachments: None,
717        tool_call_id: None,
718        tool_calls: None,
719        is_error: None,
720        is_meta: None,
721        is_api_error_message: None,
722        error_details: None,
723    }
724}
725
726/// Create a synthetic user caveat message (meta, invisible to user)
727fn make_synthetic_caveat() -> Message {
728    Message {
729        role: crate::types::MessageRole::User,
730        content: "The user didn't say anything. Continue working.".to_string(),
731        uuid: Some(uuid::Uuid::new_v4().to_string()),
732        attachments: None,
733        tool_call_id: None,
734        tool_calls: None,
735        is_error: None,
736        is_meta: Some(true),
737        is_api_error_message: None,
738        error_details: None,
739    }
740}
741
742/// Create a system local command message
743fn make_system_local_command(content: String) -> Message {
744    make_system_message(content)
745}
746
747/// Process bash command — dispatches to BashTool or PowerShellTool
748async fn process_bash_command(
749    input: String,
750    _preceding_input_blocks: Vec<ContentBlockParam>,
751    attachment_messages: Vec<Message>,
752    context: &ProcessUserInputContext,
753) -> Result<ProcessUserInputBaseResult, String> {
754    let user_message = make_user_message(
755        format!("<bash-input>{}</bash-input>", input),
756        None,
757    );
758
759    // Shell resolution: PowerShell on Windows when enabled, otherwise Bash
760    let use_powershell = crate::tools::config_tools::is_powershell_tool_enabled();
761
762    let tool_result = if use_powershell {
763        let ps_tool = crate::tools::powershell::PowerShellTool::new();
764        ps_tool
765            .execute(
766                serde_json::json!({"command": input}),
767                &crate::types::ToolContext {
768                    cwd: context.cwd.clone(),
769                    abort_signal: Default::default(),
770                },
771            )
772            .await
773    } else {
774        let bash_tool = crate::tools::bash::BashTool::new();
775        bash_tool
776            .execute(
777                serde_json::json!({"command": input}),
778                &crate::types::ToolContext {
779                    cwd: context.cwd.clone(),
780                    abort_signal: Default::default(),
781                },
782            )
783            .await
784    };
785
786    let escape_xml = crate::utils::xml::escape_xml;
787
788    match tool_result {
789        Ok(result) => {
790            let stdout = if result.content.is_empty() {
791                "".to_string()
792            } else {
793                result.content.clone()
794            };
795            let stderr = if result.is_error == Some(true) {
796                "Command completed with errors".to_string()
797            } else {
798                String::new()
799            };
800            let output_message = make_user_message(
801                format!(
802                    "<bash-stdout>{}</bash-stdout><bash-stderr>{}</bash-stderr>",
803                    escape_xml(&stdout),
804                    escape_xml(&stderr)
805                ),
806                None,
807            );
808            let mut messages = vec![
809                make_synthetic_caveat(),
810                user_message,
811            ];
812            messages.extend(attachment_messages);
813            messages.push(output_message);
814            Ok(ProcessUserInputBaseResult {
815                messages,
816                should_query: false,
817                ..Default::default()
818            })
819        }
820        Err(e) => {
821            let error_message = make_user_message(
822                format!(
823                    "<bash-stderr>Command failed: {}</bash-stderr>",
824                    escape_xml(&e.to_string())
825                ),
826                None,
827            );
828            let mut messages = vec![
829                make_synthetic_caveat(),
830                user_message,
831            ];
832            messages.extend(attachment_messages);
833            messages.push(error_message);
834            Ok(ProcessUserInputBaseResult {
835                messages,
836                should_query: false,
837                ..Default::default()
838            })
839        }
840    }
841}
842
843/// Process slash command — dispatches to registered commands
844async fn process_slash_command(
845    input: String,
846    preceding_input_blocks: Vec<ContentBlockParam>,
847    _image_content_blocks: Vec<ContentBlockParam>,
848    attachment_messages: Vec<Message>,
849    context: &ProcessUserInputContext,
850) -> Result<ProcessUserInputBaseResult, String> {
851    let parsed = parse_slash_command(&input);
852    let parsed = match parsed {
853        Some(p) => p,
854        None => {
855            let error_msg = "Commands are in the form `/command [args]`".to_string();
856            return Ok(ProcessUserInputBaseResult {
857                messages: vec![
858                    make_synthetic_caveat(),
859                ]
860                .into_iter()
861                .chain(attachment_messages.into_iter())
862                .chain(std::iter::once(make_user_message(error_msg.clone(), None)))
863                .collect(),
864                should_query: false,
865                result_text: Some(error_msg),
866                ..Default::default()
867            });
868        }
869    };
870
871    let ParsedSlashCommand {
872        command_name,
873        args,
874        is_mcp: _is_mcp,
875    } = parsed;
876
877    // Check if command exists
878    if !has_command(&command_name, &context.options.commands) {
879        // Check if it looks like a file path — if not, report as unknown skill
880        let fs = std::path::Path::new(&command_name);
881        let is_file_path = fs.exists();
882
883        if looks_like_command(&command_name) && !is_file_path {
884            let unknown_msg = format!("Unknown skill: {}", command_name);
885            let mut messages = vec![
886                make_synthetic_caveat(),
887            ];
888            messages.extend(attachment_messages);
889            messages.push(make_user_message(unknown_msg.clone(), None));
890            if !args.trim().is_empty() {
891                messages.push(make_system_message(
892                    format!("Args from unknown skill: {}", args)
893                ));
894            }
895            return Ok(ProcessUserInputBaseResult {
896                messages,
897                should_query: false,
898                result_text: Some(unknown_msg),
899                ..Default::default()
900            });
901        }
902
903        // Not a command name — treat as regular text prompt
904        let content = if preceding_input_blocks.is_empty() {
905            input
906        } else {
907            // Include preceding blocks context
908            format!("[{} blocks] {}", preceding_input_blocks.len(), input)
909        };
910        return Ok(ProcessUserInputBaseResult {
911            messages: vec![make_user_message(content, None)]
912                .into_iter()
913                .chain(attachment_messages)
914                .collect(),
915            should_query: true,
916            ..Default::default()
917        });
918    }
919
920    let command = find_command(&command_name, &context.options.commands)
921        .ok_or_else(|| format!("Command '{}' not found", command_name))?;
922
923    let command_type = command.get("type").and_then(|t| t.as_str()).unwrap_or("");
924
925    match command_type {
926        "local" => execute_local_command(command_name, args, command, preceding_input_blocks, attachment_messages, context).await,
927        "prompt" => execute_prompt_command(command_name, args, command, preceding_input_blocks, attachment_messages, context).await,
928        "local-jsx" => {
929            // Not supported in headless Rust SDK — return text result
930            let msg = format!("Command '/{}' requires a UI and is not available in this environment.", command_name);
931            Ok(ProcessUserInputBaseResult {
932                messages: vec![
933                    make_synthetic_caveat(),
934                    make_user_message(msg.clone(), None),
935                ]
936                .into_iter()
937                .chain(attachment_messages)
938                .collect(),
939                should_query: false,
940                result_text: Some(msg),
941                ..Default::default()
942            })
943        }
944        _ => {
945            let msg = format!("Unknown command type: {}", command_type);
946            Err(msg)
947        }
948    }
949}
950
951/// Execute a local command by dispatching to registered handlers
952async fn execute_local_command(
953    command_name: String,
954    args: String,
955    _command: serde_json::Value,
956    _preceding_input_blocks: Vec<ContentBlockParam>,
957    attachment_messages: Vec<Message>,
958    _context: &ProcessUserInputContext,
959) -> Result<ProcessUserInputBaseResult, String> {
960    let input_display = format_command_input_tags(&command_name, &args);
961    let user_message = make_user_message(input_display, None);
962
963    // Dispatch to built-in local command handler
964    let result = dispatch_local_command(&command_name, &args).await;
965
966    match result {
967        Ok(call_result) => {
968            use crate::commands::version::CommandCallResult;
969            match call_result.result_type.as_str() {
970                "text" => {
971                    let output = if call_result.value.is_empty() {
972                        make_system_local_command(
973                            "(no output)".to_string()
974                        )
975                    } else {
976                        make_system_local_command(
977                            format!("<local-command-stdout>{}</local-command-stdout>", call_result.value)
978                        )
979                    };
980                    let mut messages = vec![
981                        make_synthetic_caveat(),
982                        user_message,
983                    ];
984                    messages.extend(attachment_messages);
985                    messages.push(output);
986                    Ok(ProcessUserInputBaseResult {
987                        messages,
988                        should_query: false,
989                        result_text: Some(call_result.value),
990                        ..Default::default()
991                    })
992                }
993                "compact" => {
994                    // Compact result — the compaction was already performed
995                    let mut messages = vec![
996                        make_synthetic_caveat(),
997                        user_message,
998                    ];
999                    messages.extend(attachment_messages);
1000                    messages.push(make_system_local_command(
1001                        format!("<local-command-stdout>Conversation compacted</local-command-stdout>")
1002                    ));
1003                    Ok(ProcessUserInputBaseResult {
1004                        messages,
1005                        should_query: false,
1006                        result_text: Some("Conversation compacted".to_string()),
1007                        ..Default::default()
1008                    })
1009                }
1010                "skip" => Ok(ProcessUserInputBaseResult {
1011                    messages: vec![],
1012                    should_query: false,
1013                    ..Default::default()
1014                }),
1015                _ => Err(format!("Unknown local command result type: {}", call_result.result_type)),
1016            }
1017        }
1018        Err(e) => {
1019            let mut messages = vec![
1020                make_synthetic_caveat(),
1021                user_message,
1022            ];
1023            messages.extend(attachment_messages);
1024            messages.push(make_system_local_command(
1025                format!("<local-command-stderr>{}</local-command-stderr>", e)
1026            ));
1027            Ok(ProcessUserInputBaseResult {
1028                messages,
1029                should_query: false,
1030                ..Default::default()
1031            })
1032        }
1033    }
1034}
1035
1036/// Dispatch to a built-in local command handler.
1037/// Matches command name to handler function.
1038async fn dispatch_local_command(
1039    name: &str,
1040    args: &str,
1041) -> Result<crate::commands::version::CommandCallResult, String> {
1042    match name {
1043        "clear" => handle_clear_command(args),
1044        "cost" => handle_cost_command(args),
1045        "compact" => handle_compact_command(args),
1046        "version" => handle_version_command(args),
1047        "model" => handle_model_command(args),
1048        _ => {
1049            // For commands that don't have a Rust handler yet, return a text result
1050            Ok(crate::commands::version::CommandCallResult::text(
1051                format!("Command '/{}' is registered but not yet implemented in this environment.", name)
1052            ))
1053        }
1054    }
1055}
1056
1057/// Handle /clear command
1058fn handle_clear_command(args: &str) -> Result<crate::commands::version::CommandCallResult, String> {
1059    let target = args.trim().split_whitespace().next().unwrap_or("conversation");
1060    match target {
1061        "cache" => Ok(crate::commands::version::CommandCallResult::text(
1062            "Cache cleared."
1063        )),
1064        "all" => Ok(crate::commands::version::CommandCallResult::text(
1065            "Conversation and cache cleared."
1066        )),
1067        _ => Ok(crate::commands::version::CommandCallResult::text("")),
1068    }
1069}
1070
1071/// Handle /cost command
1072fn handle_cost_command(_args: &str) -> Result<crate::commands::version::CommandCallResult, String> {
1073    // Cost tracking is session-state dependent; without access to the session
1074    // state we can only report that cost tracking is available.
1075    Ok(crate::commands::version::CommandCallResult::text(
1076        "Cost tracking is available through the session's cost tracker."
1077    ))
1078}
1079
1080/// Handle /compact command
1081fn handle_compact_command(_args: &str) -> Result<crate::commands::version::CommandCallResult, String> {
1082    // Compact requires session state manipulation which is handled by the query engine.
1083    // Return a text result indicating compaction was requested.
1084    Ok(crate::commands::version::CommandCallResult {
1085        result_type: "compact".to_string(),
1086        value: "Compact requested".to_string(),
1087    })
1088}
1089
1090/// Handle /version command
1091fn handle_version_command(_args: &str) -> Result<crate::commands::version::CommandCallResult, String> {
1092    let version = env!("CARGO_PKG_VERSION");
1093    Ok(crate::commands::version::CommandCallResult::text(version))
1094}
1095
1096/// Handle /model command
1097fn handle_model_command(_args: &str) -> Result<crate::commands::version::CommandCallResult, String> {
1098    Ok(crate::commands::version::CommandCallResult::text(
1099        "Model configuration is managed through session settings."
1100    ))
1101}
1102
1103/// Execute a prompt command by expanding its prompt and sending to the model
1104async fn execute_prompt_command(
1105    command_name: String,
1106    args: String,
1107    command: serde_json::Value,
1108    preceding_input_blocks: Vec<ContentBlockParam>,
1109    attachment_messages: Vec<Message>,
1110    _context: &ProcessUserInputContext,
1111) -> Result<ProcessUserInputBaseResult, String> {
1112    let input_display = format_command_input_tags(&command_name, &args);
1113    let progress_message = command.get("progressMessage").and_then(|p| p.as_str()).unwrap_or("Loading");
1114    let model = command.get("model").and_then(|m| m.as_str()).map(String::from);
1115    let allowed_tools = command
1116        .get("allowedTools")
1117        .and_then(|t| t.as_array())
1118        .map(|t| t.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<String>>());
1119    let effort = command.get("effort").and_then(|e| e.as_str()).map(|e| {
1120        crate::utils::process_user_input::EffortValue {
1121            effort: e.to_string(),
1122            reason: None,
1123        }
1124    });
1125
1126    // Build prompt content from the command's content field
1127    let content = command.get("content").and_then(|c| c.as_str()).unwrap_or("");
1128    let prompt_text = if !args.trim().is_empty() {
1129        format!("{}\n\nArguments: {}", content, args)
1130    } else {
1131        content.to_string()
1132    };
1133
1134    // If there are preceding input blocks, include them
1135    let full_content = if preceding_input_blocks.is_empty() {
1136        prompt_text
1137    } else {
1138        format!("[{} preceding blocks]\n{}", preceding_input_blocks.len(), prompt_text)
1139    };
1140
1141    let mut messages = vec![
1142        make_system_message(
1143            format!("[{}] {}", progress_message, command_name)
1144        ),
1145        make_user_message(input_display, None),
1146    ];
1147    messages.extend(attachment_messages);
1148    messages.push(make_user_message(full_content, None));
1149
1150    Ok(ProcessUserInputBaseResult {
1151        messages,
1152        should_query: true,
1153        allowed_tools,
1154        model,
1155        effort,
1156        ..Default::default()
1157    })
1158}
1159
1160#[cfg(test)]
1161mod tests {
1162    use super::*;
1163
1164    #[test]
1165    fn test_process_user_input_default() {
1166        let options = ProcessUserInputOptions::default();
1167        assert!(matches!(options.input, ProcessUserInput::String(s) if s.is_empty()));
1168        assert_eq!(options.mode, PromptInputMode::Prompt);
1169    }
1170
1171    #[test]
1172    fn test_process_text_prompt() {
1173        let result = process_text_prompt(
1174            ProcessUserInput::String("Hello".to_string()),
1175            vec![],
1176            vec![],
1177            Some("test-uuid".to_string()),
1178            None,
1179            Some(true),
1180        )
1181        .unwrap();
1182
1183        assert!(result.should_query);
1184        assert_eq!(result.messages.len(), 1);
1185    }
1186
1187    #[test]
1188    fn test_parse_slash_command_basic() {
1189        let parsed = parse_slash_command("/compact").unwrap();
1190        assert_eq!(parsed.command_name, "compact");
1191        assert_eq!(parsed.args, "");
1192        assert!(!parsed.is_mcp);
1193    }
1194
1195    #[test]
1196    fn test_parse_slash_command_with_args() {
1197        let parsed = parse_slash_command("/model opus").unwrap();
1198        assert_eq!(parsed.command_name, "model");
1199        assert_eq!(parsed.args, "opus");
1200        assert!(!parsed.is_mcp);
1201    }
1202
1203    #[test]
1204    fn test_parse_slash_command_mcp() {
1205        let parsed = parse_slash_command("/my-tool (MCP) arg1 arg2").unwrap();
1206        assert_eq!(parsed.command_name, "my-tool (MCP)");
1207        assert_eq!(parsed.args, "arg1 arg2");
1208        assert!(parsed.is_mcp);
1209    }
1210
1211    #[test]
1212    fn test_parse_slash_command_no_slash() {
1213        assert!(parse_slash_command("hello").is_none());
1214    }
1215
1216    #[test]
1217    fn test_parse_slash_command_empty() {
1218        assert!(parse_slash_command("/").is_none());
1219    }
1220
1221    #[test]
1222    fn test_parse_slash_command_spaces_only() {
1223        assert!(parse_slash_command("/ ").is_none());
1224    }
1225
1226    #[test]
1227    fn test_looks_like_command_valid() {
1228        assert!(looks_like_command("compact"));
1229        assert!(looks_like_command("my-command"));
1230        assert!(looks_like_command("my_command"));
1231        assert!(looks_like_command("my:command"));
1232        assert!(looks_like_command("cmd123"));
1233    }
1234
1235    #[test]
1236    fn test_looks_like_command_invalid() {
1237        assert!(!looks_like_command("/var/log"));
1238        assert!(!looks_like_command("file.txt"));
1239        assert!(!looks_like_command("path/to/file"));
1240    }
1241
1242    #[test]
1243    fn test_has_command() {
1244        let commands = vec![
1245            serde_json::json!({"name": "clear", "type": "local"}),
1246            serde_json::json!({"name": "compact", "type": "local", "aliases": ["summarize"]}),
1247        ];
1248        assert!(has_command("clear", &commands));
1249        assert!(has_command("compact", &commands));
1250        assert!(has_command("summarize", &commands));
1251        assert!(!has_command("unknown", &commands));
1252    }
1253
1254    #[test]
1255    fn test_find_command_by_name() {
1256        let commands = vec![
1257            serde_json::json!({"name": "clear", "type": "local"}),
1258        ];
1259        let cmd = find_command("clear", &commands).unwrap();
1260        assert_eq!(cmd["name"], "clear");
1261    }
1262
1263    #[test]
1264    fn test_find_command_by_alias() {
1265        let commands = vec![
1266            serde_json::json!({"name": "compact", "aliases": ["summarize"]}),
1267        ];
1268        let cmd = find_command("summarize", &commands).unwrap();
1269        assert_eq!(cmd["name"], "compact");
1270    }
1271
1272    #[test]
1273    fn test_format_command_input_tags() {
1274        let tags = format_command_input_tags("compact", "");
1275        assert!(tags.contains("<command-message>compact</command-message>"));
1276        assert!(tags.contains("<command-name>/compact</command-name>"));
1277        assert!(!tags.contains("<command-args>"));
1278    }
1279
1280    #[test]
1281    fn test_format_command_input_tags_with_args() {
1282        let tags = format_command_input_tags("model", "opus");
1283        assert!(tags.contains("<command-message>model</command-message>"));
1284        assert!(tags.contains("<command-name>/model</command-name>"));
1285        assert!(tags.contains("<command-args>opus</command-args>"));
1286    }
1287
1288    #[tokio::test]
1289    async fn test_dispatch_clear_command() {
1290        let result = dispatch_local_command("clear", "").await.unwrap();
1291        assert_eq!(result.result_type, "text");
1292        assert_eq!(result.value, "");
1293    }
1294
1295    #[tokio::test]
1296    async fn test_dispatch_clear_cache_command() {
1297        let result = dispatch_local_command("clear", "cache").await.unwrap();
1298        assert_eq!(result.result_type, "text");
1299        assert!(result.value.contains("Cache cleared"));
1300    }
1301
1302    #[tokio::test]
1303    async fn test_dispatch_version_command() {
1304        let result = dispatch_local_command("version", "").await.unwrap();
1305        assert_eq!(result.result_type, "text");
1306        assert!(!result.value.is_empty());
1307    }
1308
1309    #[tokio::test]
1310    async fn test_dispatch_unknown_command() {
1311        let result = dispatch_local_command("unknown-cmd", "").await.unwrap();
1312        assert_eq!(result.result_type, "text");
1313        assert!(result.value.contains("not yet implemented"));
1314    }
1315
1316    #[tokio::test]
1317    async fn test_dispatch_compact_command() {
1318        let result = dispatch_local_command("compact", "").await.unwrap();
1319        assert_eq!(result.result_type, "compact");
1320    }
1321
1322    #[tokio::test]
1323    async fn test_process_slash_command_invalid() {
1324        let context = ProcessUserInputContext::default();
1325        let result = process_slash_command(
1326            "hello".to_string(),
1327            vec![],
1328            vec![],
1329            vec![],
1330            &context,
1331        ).await;
1332        assert!(result.is_ok());
1333        let r = result.unwrap();
1334        assert!(!r.should_query);
1335        assert!(r.result_text.as_ref().unwrap().contains("Commands are in the form"));
1336    }
1337
1338    #[tokio::test]
1339    async fn test_process_slash_command_unknown() {
1340        let context = ProcessUserInputContext::default();
1341        let result = process_slash_command(
1342            "/nonexistent-command".to_string(),
1343            vec![],
1344            vec![],
1345            vec![],
1346            &context,
1347        ).await;
1348        assert!(result.is_ok());
1349        let r = result.unwrap();
1350        assert!(!r.should_query);
1351        assert!(r.result_text.as_ref().unwrap().contains("Unknown skill"));
1352    }
1353
1354    #[tokio::test]
1355    async fn test_process_slash_command_known_local() {
1356        let mut commands = vec![];
1357        commands.push(serde_json::json!({"name": "clear", "type": "local"}));
1358        let context = ProcessUserInputContext {
1359            options: ProcessUserInputContextOptions {
1360                commands,
1361                ..Default::default()
1362            },
1363            ..Default::default()
1364        };
1365        let result = process_slash_command(
1366            "/clear".to_string(),
1367            vec![],
1368            vec![],
1369            vec![],
1370            &context,
1371        ).await;
1372        assert!(result.is_ok());
1373        let r = result.unwrap();
1374        assert!(!r.should_query);
1375    }
1376
1377    #[tokio::test]
1378    async fn test_process_bash_command_echo() {
1379        let context = ProcessUserInputContext::default();
1380        let result = process_bash_command(
1381            "echo hello".to_string(),
1382            vec![],
1383            vec![],
1384            &context,
1385        ).await;
1386        assert!(result.is_ok());
1387        let r = result.unwrap();
1388        assert!(!r.should_query);
1389        assert!(!r.messages.is_empty());
1390    }
1391}