Files
optimclaw/src/agent/submission.rs
T
Nick PismenkovandGitHub 9ae04f14e3 feat: restart (#531)
* feat: restart

* review fixes

* add IRONCLAW_IN_DOCKER env variable

* review fixes

* fix tests

* set default value as false
2026-03-05 17:12:49 -08:00

850 lines
26 KiB
Rust

//! 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<String> = 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 == "/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<String> = 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 <uuid> - 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 <uuid> - 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::<Submission>(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<String>,
},
/// 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<String>,
},
}
impl Submission {
/// Create a user input submission.
pub fn user_input(content: impl Into<String>) -> 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,
},
/// Successfully processed (for control commands).
Ok {
/// Optional message.
message: Option<String>,
},
/// Error occurred.
Error {
/// Error message.
message: String,
},
/// Turn was interrupted.
Interrupted,
}
impl SubmissionResult {
/// Create a response result.
pub fn response(content: impl Into<String>) -> 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<String>) -> Self {
Self::Ok {
message: Some(message.into()),
}
}
/// Create an error result.
pub fn error(message: impl Into<String>) -> Self {
Self::Error {
message: 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));
}
}