//! Submission types for the turn-based agent loop. //! //! Submissions are the different types of input the agent can receive //! and process as part of the turn-based development loop. use serde::{Deserialize, Serialize}; use uuid::Uuid; /// Parses user input into Submission types. pub struct SubmissionParser; impl SubmissionParser { /// Parse message content into a Submission. pub fn parse(content: &str) -> Submission { let trimmed = content.trim(); let lower = trimmed.to_lowercase(); tracing::debug!("[SubmissionParser::parse] Parsing input: {:?}", trimmed); // Control commands (exact match or prefix) if lower == "/undo" { return Submission::Undo; } if lower == "/redo" { return Submission::Redo; } if lower == "/interrupt" || lower == "/stop" { return Submission::Interrupt; } if lower == "/compact" { return Submission::Compact; } if lower == "/clear" { return Submission::Clear; } if lower == "/heartbeat" { return Submission::Heartbeat; } if lower == "/summarize" || lower == "/summary" { return Submission::Summarize; } if lower == "/suggest" { return Submission::Suggest; } if lower == "/thread new" || lower == "/new" { return Submission::NewThread; } // System commands (bypass thread-state checks) if lower == "/help" || lower == "/?" { return Submission::SystemCommand { command: "help".to_string(), args: vec![], }; } if lower == "/version" { return Submission::SystemCommand { command: "version".to_string(), args: vec![], }; } if lower == "/tools" { return Submission::SystemCommand { command: "tools".to_string(), args: vec![], }; } if lower == "/skills" { return Submission::SystemCommand { command: "skills".to_string(), args: vec![], }; } if lower.starts_with("/skills ") { let args: Vec = trimmed .split_whitespace() .skip(1) .map(|s| s.to_string()) .collect(); return Submission::SystemCommand { command: "skills".to_string(), args, }; } if lower == "/ping" { return Submission::SystemCommand { command: "ping".to_string(), args: vec![], }; } if lower == "/debug" { return Submission::SystemCommand { command: "debug".to_string(), args: vec![], }; } if lower == "/reasoning" || lower.starts_with("/reasoning ") { let args: Vec = trimmed .split_whitespace() .skip(1) .map(|s| s.to_string()) .collect(); return Submission::SystemCommand { command: "reasoning".to_string(), args, }; } if lower == "/restart" { tracing::debug!("[SubmissionParser::parse] Recognized /restart command"); return Submission::SystemCommand { command: "restart".to_string(), args: vec![], }; } if lower.starts_with("/model") { let args: Vec = trimmed .split_whitespace() .skip(1) .map(|s| s.to_string()) .collect(); return Submission::SystemCommand { command: "model".to_string(), args, }; } if lower == "/quit" || lower == "/exit" || lower == "/shutdown" { return Submission::Quit; } // Job commands if lower == "/status" || lower == "/progress" { return Submission::JobStatus { job_id: None }; } if let Some(rest) = lower .strip_prefix("/status ") .or_else(|| lower.strip_prefix("/progress ")) { let id = rest.trim().to_string(); if !id.is_empty() { return Submission::JobStatus { job_id: Some(id) }; } } if lower == "/list" { return Submission::JobStatus { job_id: None }; } if let Some(rest) = lower.strip_prefix("/cancel ") { let id = rest.trim().to_string(); if !id.is_empty() { return Submission::JobCancel { job_id: id }; } } // /thread - switch thread if let Some(rest) = lower.strip_prefix("/thread ") { let rest = rest.trim(); if rest != "new" && let Ok(id) = Uuid::parse_str(rest) { return Submission::SwitchThread { thread_id: id }; } } // /resume - resume from checkpoint if let Some(rest) = lower.strip_prefix("/resume ") && let Ok(id) = Uuid::parse_str(rest.trim()) { return Submission::Resume { checkpoint_id: id }; } // Try structured JSON approval (from web gateway's /api/chat/approval endpoint) if trimmed.starts_with('{') && let Ok(submission) = serde_json::from_str::(trimmed) && matches!(submission, Submission::ExecApproval { .. }) { return submission; } // Approval responses (simple yes/no/always for pending approvals) // These are short enough to check explicitly match lower.as_str() { "yes" | "y" | "approve" | "ok" | "/approve" | "/yes" | "/y" => { return Submission::ApprovalResponse { approved: true, always: false, }; } "always" | "a" | "yes always" | "approve always" | "/always" | "/a" => { return Submission::ApprovalResponse { approved: true, always: true, }; } "no" | "n" | "deny" | "reject" | "cancel" | "/deny" | "/no" | "/n" => { return Submission::ApprovalResponse { approved: false, always: false, }; } _ => {} } // Default: user input Submission::UserInput { content: content.to_string(), } } } /// A submission to the agent. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Submission { /// User text input (starts a new turn). UserInput { /// The user's message content. content: String, }, /// Response to an execution approval request (with explicit request ID). ExecApproval { /// ID of the approval request being responded to. request_id: Uuid, /// Whether the execution was approved. approved: bool, /// If true, auto-approve this tool for the rest of the session. always: bool, }, /// Simple approval response (yes/no/always) for the current pending approval. ApprovalResponse { /// Whether the execution was approved. approved: bool, /// If true, auto-approve this tool for the rest of the session. always: bool, }, /// Interrupt the current turn. Interrupt, /// Request context compaction. Compact, /// Undo the last turn. Undo, /// Redo a previously undone turn (if available). Redo, /// Resume from a specific checkpoint. Resume { /// ID of the checkpoint to resume from. checkpoint_id: Uuid, }, /// Clear the current thread and start fresh. Clear, /// Switch to a different thread. SwitchThread { /// ID of the thread to switch to. thread_id: Uuid, }, /// Create a new thread. NewThread, /// Trigger a manual heartbeat check. Heartbeat, /// Summarize the current thread. Summarize, /// Suggest next steps based on the current thread. Suggest, /// Check job status. No job_id shows all jobs; with job_id shows a specific job. JobStatus { /// Optional job ID (UUID or short prefix). If None, shows all jobs. job_id: Option, }, /// Cancel a running job. JobCancel { /// Job ID (UUID or short prefix). job_id: String, }, /// Quit the agent. Bypasses thread-state checks. Quit, /// System command (help, model, version, tools, ping, debug). /// Bypasses thread-state checks and safety validation. SystemCommand { /// The command name (e.g. "help", "model", "version"). command: String, /// Arguments to the command. args: Vec, }, } impl Submission { /// Create a user input submission. pub fn user_input(content: impl Into) -> Self { Self::UserInput { content: content.into(), } } /// Create an approval submission. #[cfg(test)] pub fn approval(request_id: Uuid, approved: bool) -> Self { Self::ExecApproval { request_id, approved, always: false, } } /// Create an "always approve" submission. #[cfg(test)] pub fn always_approve(request_id: Uuid) -> Self { Self::ExecApproval { request_id, approved: true, always: true, } } /// Create an interrupt submission. #[cfg(test)] pub fn interrupt() -> Self { Self::Interrupt } /// Create a compact submission. #[cfg(test)] pub fn compact() -> Self { Self::Compact } /// Create an undo submission. #[cfg(test)] pub fn undo() -> Self { Self::Undo } /// Create a redo submission. #[cfg(test)] pub fn redo() -> Self { Self::Redo } /// Check if this submission starts a new turn. #[cfg(test)] pub fn starts_turn(&self) -> bool { matches!(self, Self::UserInput { .. }) } /// Check if this submission is a control command. pub fn is_control(&self) -> bool { matches!( self, Self::Interrupt | Self::Compact | Self::Undo | Self::Redo | Self::Clear | Self::NewThread | Self::Heartbeat | Self::Summarize | Self::Suggest | Self::JobStatus { .. } | Self::JobCancel { .. } | Self::SystemCommand { .. } ) } } /// Result of processing a submission. #[derive(Debug, Clone)] pub enum SubmissionResult { /// Turn completed with a response. Response { /// The agent's response. content: String, }, /// Need approval before continuing. NeedApproval { /// ID of the approval request. request_id: Uuid, /// Tool that needs approval. tool_name: String, /// Description of what the tool will do. description: String, /// Parameters being passed. parameters: serde_json::Value, /// Whether "always" auto-approve should be offered to the user. allow_always: bool, }, /// Successfully processed (for control commands). Ok { /// Optional message. message: Option, }, /// Error occurred. Error { /// Error message. message: String, }, /// Turn was interrupted. Interrupted, } impl SubmissionResult { /// Create a response result. pub fn response(content: impl Into) -> Self { Self::Response { content: content.into(), } } /// Create an OK result. #[cfg(test)] pub fn ok() -> Self { Self::Ok { message: None } } /// Create an OK result with a message. pub fn ok_with_message(message: impl Into) -> Self { Self::Ok { message: Some(message.into()), } } /// Create an error result. pub fn error(message: impl Into) -> Self { Self::Error { message: message.into(), } } /// Create a non-error status message (e.g., for blocking states like approval waiting). /// Uses Ok variant to avoid "Error:" prefix in rendering. pub fn pending(message: impl Into) -> Self { Self::Ok { message: Some(message.into()), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_submission_types() { let input = Submission::user_input("Hello"); assert!(input.starts_turn()); assert!(!input.is_control()); let undo = Submission::undo(); assert!(!undo.starts_turn()); assert!(undo.is_control()); } #[test] fn test_parser_user_input() { let submission = SubmissionParser::parse("Hello, how are you?"); assert!( matches!(submission, Submission::UserInput { content } if content == "Hello, how are you?") ); } #[test] fn test_parser_undo() { let submission = SubmissionParser::parse("/undo"); assert!(matches!(submission, Submission::Undo)); let submission = SubmissionParser::parse("/UNDO"); assert!(matches!(submission, Submission::Undo)); } #[test] fn test_parser_redo() { let submission = SubmissionParser::parse("/redo"); assert!(matches!(submission, Submission::Redo)); } #[test] fn test_parser_interrupt() { let submission = SubmissionParser::parse("/interrupt"); assert!(matches!(submission, Submission::Interrupt)); let submission = SubmissionParser::parse("/stop"); assert!(matches!(submission, Submission::Interrupt)); } #[test] fn test_parser_compact() { let submission = SubmissionParser::parse("/compact"); assert!(matches!(submission, Submission::Compact)); } #[test] fn test_parser_clear() { let submission = SubmissionParser::parse("/clear"); assert!(matches!(submission, Submission::Clear)); } #[test] fn test_parser_new_thread() { let submission = SubmissionParser::parse("/thread new"); assert!(matches!(submission, Submission::NewThread)); let submission = SubmissionParser::parse("/new"); assert!(matches!(submission, Submission::NewThread)); } #[test] fn test_parser_switch_thread() { let uuid = Uuid::new_v4(); let submission = SubmissionParser::parse(&format!("/thread {}", uuid)); assert!(matches!(submission, Submission::SwitchThread { thread_id } if thread_id == uuid)); } #[test] fn test_parser_resume() { let uuid = Uuid::new_v4(); let submission = SubmissionParser::parse(&format!("/resume {}", uuid)); assert!( matches!(submission, Submission::Resume { checkpoint_id } if checkpoint_id == uuid) ); } #[test] fn test_parser_heartbeat() { let submission = SubmissionParser::parse("/heartbeat"); assert!(matches!(submission, Submission::Heartbeat)); } #[test] fn test_parser_summarize() { let submission = SubmissionParser::parse("/summarize"); assert!(matches!(submission, Submission::Summarize)); let submission = SubmissionParser::parse("/summary"); assert!(matches!(submission, Submission::Summarize)); } #[test] fn test_parser_suggest() { let submission = SubmissionParser::parse("/suggest"); assert!(matches!(submission, Submission::Suggest)); } #[test] fn test_parser_invalid_commands_become_user_input() { // Invalid UUID should become user input let submission = SubmissionParser::parse("/thread not-a-uuid"); assert!(matches!(submission, Submission::UserInput { .. })); // Unknown command should become user input let submission = SubmissionParser::parse("/unknown"); assert!(matches!(submission, Submission::UserInput { content } if content == "/unknown")); } #[test] fn test_parser_approval_response_aliases() { // approve once assert!(matches!( SubmissionParser::parse("y"), Submission::ApprovalResponse { approved: true, always: false } )); assert!(matches!( SubmissionParser::parse("/approve"), Submission::ApprovalResponse { approved: true, always: false } )); // approve always assert!(matches!( SubmissionParser::parse("a"), Submission::ApprovalResponse { approved: true, always: true } )); assert!(matches!( SubmissionParser::parse("/always"), Submission::ApprovalResponse { approved: true, always: true } )); // deny assert!(matches!( SubmissionParser::parse("n"), Submission::ApprovalResponse { approved: false, always: false } )); assert!(matches!( SubmissionParser::parse("/deny"), Submission::ApprovalResponse { approved: false, always: false } )); } #[test] fn test_parser_json_exec_approval() { let req_id = Uuid::new_v4(); let json = serde_json::to_string(&Submission::ExecApproval { request_id: req_id, approved: true, always: false, }) .expect("serialize"); let submission = SubmissionParser::parse(&json); assert!( matches!(submission, Submission::ExecApproval { request_id, approved, always } if request_id == req_id && approved && !always) ); } #[test] fn test_parser_json_exec_approval_always() { let req_id = Uuid::new_v4(); let json = serde_json::to_string(&Submission::ExecApproval { request_id: req_id, approved: true, always: true, }) .expect("serialize"); let submission = SubmissionParser::parse(&json); assert!( matches!(submission, Submission::ExecApproval { request_id, approved, always } if request_id == req_id && approved && always) ); } #[test] fn test_parser_json_exec_approval_deny() { let req_id = Uuid::new_v4(); let json = serde_json::to_string(&Submission::ExecApproval { request_id: req_id, approved: false, always: false, }) .expect("serialize"); let submission = SubmissionParser::parse(&json); assert!( matches!(submission, Submission::ExecApproval { request_id, approved, always } if request_id == req_id && !approved && !always) ); } #[test] fn test_parser_json_non_approval_stays_user_input() { // A JSON UserInput should NOT be intercepted, it should be treated as text let json = r#"{"UserInput":{"content":"hello"}}"#; let submission = SubmissionParser::parse(json); assert!(matches!(submission, Submission::UserInput { .. })); } #[test] fn test_parser_json_roundtrip_matches_approval_handler() { // Simulate exactly what chat_approval_handler does: serialize a Submission::ExecApproval // and verify the parser picks it up correctly. let request_id = Uuid::new_v4(); let approval = Submission::ExecApproval { request_id, approved: true, always: false, }; let json = serde_json::to_string(&approval).expect("serialize"); eprintln!("Serialized approval JSON: {}", json); let parsed = SubmissionParser::parse(&json); assert!( matches!(parsed, Submission::ExecApproval { request_id: rid, approved, always } if rid == request_id && approved && !always), "Expected ExecApproval, got {:?}", parsed ); } #[test] fn test_parser_system_command_help() { let submission = SubmissionParser::parse("/help"); assert!( matches!(submission, Submission::SystemCommand { command, args } if command == "help" && args.is_empty()) ); let submission = SubmissionParser::parse("/?"); assert!( matches!(submission, Submission::SystemCommand { command, .. } if command == "help") ); let submission = SubmissionParser::parse("/HELP"); assert!( matches!(submission, Submission::SystemCommand { command, .. } if command == "help") ); } #[test] fn test_parser_system_command_model() { // No args: show current model let submission = SubmissionParser::parse("/model"); assert!( matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args.is_empty()) ); // With args: switch model let submission = SubmissionParser::parse("/model gpt-4o"); assert!( matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args == vec!["gpt-4o"]) ); // Case insensitive command, preserves arg case let submission = SubmissionParser::parse("/MODEL Claude-3.5"); assert!( matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args == vec!["Claude-3.5"]) ); } #[test] fn test_parser_system_command_version() { let submission = SubmissionParser::parse("/version"); assert!( matches!(submission, Submission::SystemCommand { command, args } if command == "version" && args.is_empty()) ); } #[test] fn test_parser_system_command_tools() { let submission = SubmissionParser::parse("/tools"); assert!( matches!(submission, Submission::SystemCommand { command, args } if command == "tools" && args.is_empty()) ); } #[test] fn test_parser_system_command_ping() { let submission = SubmissionParser::parse("/ping"); assert!( matches!(submission, Submission::SystemCommand { command, args } if command == "ping" && args.is_empty()) ); } #[test] fn test_parser_system_command_debug() { let submission = SubmissionParser::parse("/debug"); assert!( matches!(submission, Submission::SystemCommand { command, args } if command == "debug" && args.is_empty()) ); } #[test] fn test_parser_system_command_is_control() { let submission = SubmissionParser::parse("/help"); assert!(submission.is_control()); assert!(!submission.starts_turn()); } #[test] fn test_parser_system_command_skills() { let submission = SubmissionParser::parse("/skills"); assert!( matches!(submission, Submission::SystemCommand { command, args } if command == "skills" && args.is_empty()) ); // Case insensitive let submission = SubmissionParser::parse("/SKILLS"); assert!( matches!(submission, Submission::SystemCommand { command, .. } if command == "skills") ); } #[test] fn test_parser_system_command_skills_search() { let submission = SubmissionParser::parse("/skills search markdown"); assert!( matches!(submission, Submission::SystemCommand { command, args } if command == "skills" && args == vec!["search", "markdown"]) ); // Multiple words in query let submission = SubmissionParser::parse("/skills search code review tools"); assert!( matches!(submission, Submission::SystemCommand { command, args } if command == "skills" && args == vec!["search", "code", "review", "tools"]) ); } #[test] fn test_parser_job_status() { // /status with no id → all jobs let s = SubmissionParser::parse("/status"); assert!(matches!(s, Submission::JobStatus { job_id: None })); // /progress alias let s = SubmissionParser::parse("/progress"); assert!(matches!(s, Submission::JobStatus { job_id: None })); // /status with id let s = SubmissionParser::parse("/status abc123"); assert!(matches!(s, Submission::JobStatus { job_id: Some(id) } if id == "abc123")); // /progress with id let s = SubmissionParser::parse("/progress abc123"); assert!(matches!(s, Submission::JobStatus { job_id: Some(id) } if id == "abc123")); // case insensitive let s = SubmissionParser::parse("/STATUS"); assert!(matches!(s, Submission::JobStatus { job_id: None })); } #[test] fn test_parser_job_list() { // /list is an alias for /status with no job_id let s = SubmissionParser::parse("/list"); assert!(matches!(s, Submission::JobStatus { job_id: None })); let s = SubmissionParser::parse("/LIST"); assert!(matches!(s, Submission::JobStatus { job_id: None })); } #[test] fn test_parser_job_cancel() { let s = SubmissionParser::parse("/cancel abc123"); assert!(matches!(s, Submission::JobCancel { job_id } if job_id == "abc123")); // /cancel with no id → falls through to UserInput let s = SubmissionParser::parse("/cancel"); assert!(matches!(s, Submission::UserInput { .. })); } #[test] fn test_job_commands_are_control() { assert!(SubmissionParser::parse("/status").is_control()); assert!(SubmissionParser::parse("/list").is_control()); assert!(SubmissionParser::parse("/cancel abc").is_control()); } #[test] fn test_parser_quit() { assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit)); assert!(matches!(SubmissionParser::parse("/exit"), Submission::Quit)); assert!(matches!( SubmissionParser::parse("/shutdown"), Submission::Quit )); assert!(matches!(SubmissionParser::parse("/QUIT"), Submission::Quit)); assert!(matches!(SubmissionParser::parse("/Exit"), Submission::Quit)); } }