mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(agent): thread per-tool reasoning from LLM through to REPL, HTTP, SSE, and DB Add end-to-end agent reasoning summaries so users can see *why* the agent chose specific tools, not just what it did. - Add `reasoning: Option<String>` to `ToolCall` (all providers) - Populate from LLM response content in `Reasoning::respond_with_tools` and `select_tools`, with per-tool override when providers supply it - Extend `Turn` with `narrative` and `TurnToolCall` with `rationale` + `tool_call_id` for identity-based result matching - Persist reasoning in DB via existing tool_calls JSON (no migration) - Add `StatusUpdate::ReasoningUpdate` and `SseEvent::ReasoningUpdate` + `SseEvent::JobReasoning` for real-time streaming - Emit reasoning events in both chat dispatcher and worker job path - Add `/reasoning [N|all]` command for inspecting turn reasoning - Surface `narrative` and `rationale` in HTTP `/api/chat/history` Based on the design from #361 and #456, reconstructed cleanly with Option<String> to minimize blast radius (vs mandatory String that broke compilation in #456). Closes #456 Co-Authored-By: panosAthDBX <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review feedback from Gemini and Copilot - Fix `_ => Ok(None)` in agent_loop.rs to avoid accidental shutdown - Fix fallback in record_tool_result_for/record_tool_error_for to use first pending call instead of last_mut (parallel execution safety) - Include per-tool decisions in WASM channel reasoning messages - Apply truncate_at_tool_tags + clean_response to shared_reasoning in select_tools (parity with respond_with_tools) - Persist turn-level narrative to DB in tool_calls JSON wrapper - Parse both old (array) and new (object) tool_calls formats in build_turns_from_db_messages for backward compatibility - Populate reasoning from action.reasoning in execute_plan ToolCalls [skip-regression-check] Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address second round of review comments + merge fixes - Add reasoning: None to new github_copilot.rs ToolCall sites (from staging merge) - Run cargo fmt on 4 files with formatting diffs - Truncate narrative to 1000 chars before DB persistence - Clone turn data and drop session lock in /reasoning command - Extract ToolDecisionDto::from_json_array shared helper (deduplicate worker/job.rs and orchestrator/api.rs) - Add unit tests for wrapped tool_calls JSON format with narrative [skip-regression-check] Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address third round of review comments (Copilot + serrrfirat) - Reword ToolCall.reasoning docstring to reflect provider-supplied or fallback contract - Sanitize narrative through SafetyLayer before storage/emission - Clean per-tool reasoning via truncate_at_tool_tags + clean_response in select_tools (parity with shared reasoning) - Convert 4 approval-path recording sites in thread_ops.rs to identity-based record_tool_result_for/record_tool_error_for - Preserve tool_call_id and reasoning through restore_from_messages - Fix has_result/has_error to reject JSON null values - Truncate tool_call_id to 128 chars before DB persistence - Add 4 unit tests for record_tool_result_for/error_for edge cases Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address zmanian review — sanitize JobDelegate reasoning + warn on dropped results - Sanitize narrative and per-tool rationale through SafetyLayer in JobDelegate reasoning events (parity with ChatDelegate) - Add tracing::warn when record_tool_result_for/error_for drops a result because no matching or pending tool call exists - Add 3 unit tests for reasoning normalization (thinking tags, tool tags, empty-after-cleaning) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address 4 remaining unreplied review comments - Clean per-tool reasoning in respond_with_tools via truncate_at_tool_tags + clean_response (parity with select_tools) - Handle wrapped JSON format in rebuild_chat_messages_from_db so cold hydration works after persist_tool_calls format change - Update persist_tool_calls doc comment to describe new JSON shape - Sanitize per-tool rationale through SafetyLayer in ChatDelegate before emission and storage (parity with JobDelegate) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address zmanian review round 2 - Add tracing::debug on fallback-to-pending path in record_tool_result_for and record_tool_error_for (item 1) - Add comment explaining why /reasoning is special-cased in agent_loop.rs (item 4) - Items 2 (narrative persistence), 3 (rationale sanitization), and 5 (catch-all fix) were already addressed in prior commits Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: panosAthDBX <[email protected]> Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
871 lines
26 KiB
Rust
871 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 == "/reasoning" || lower.starts_with("/reasoning ") {
|
|
let args: Vec<String> = 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<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,
|
|
/// Whether "always" auto-approve should be offered to the user.
|
|
allow_always: bool,
|
|
},
|
|
|
|
/// 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(),
|
|
}
|
|
}
|
|
|
|
/// 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<String>) -> 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));
|
|
}
|
|
}
|