343 lines
12 KiB
Rust
343 lines
12 KiB
Rust
//! Pure request dispatch logic — no side effects.
|
|
//!
|
|
//! Contains the branching logic for resolving permission responses and
|
|
//! classifying incoming requests. All functions are pure data transformations;
|
|
//! I/O (socket reads, spawning tasks) lives in `io.rs`.
|
|
|
|
use crate::http::context::PermissionDecision;
|
|
use std::collections::HashMap;
|
|
use tokio::sync::oneshot;
|
|
|
|
use super::message::{WsRequest, WsResponse};
|
|
|
|
/// The result of dispatching a single `WsRequest` outside an active chat session.
|
|
pub enum DispatchResult {
|
|
/// Start a chat session with the given messages and config.
|
|
StartChat {
|
|
messages: Vec<crate::llm::types::Message>,
|
|
config: crate::llm::chat::ProviderConfig,
|
|
},
|
|
/// Cancel the current chat session.
|
|
CancelChat,
|
|
/// Respond with a pong.
|
|
Pong,
|
|
/// Permission response outside an active chat — silently ignored.
|
|
IgnoredPermission,
|
|
/// Start a side question.
|
|
StartSideQuestion {
|
|
question: String,
|
|
context_messages: Vec<crate::llm::types::Message>,
|
|
config: crate::llm::chat::ProviderConfig,
|
|
},
|
|
/// The request could not be parsed.
|
|
ParseError(String),
|
|
}
|
|
|
|
/// Parse a raw JSON text into a [`DispatchResult`].
|
|
///
|
|
/// This is the outer-loop dispatch: determines what action to take for a
|
|
/// message received when no chat session is active.
|
|
pub fn dispatch_outer(text: &str) -> DispatchResult {
|
|
match serde_json::from_str::<WsRequest>(text) {
|
|
Ok(WsRequest::Chat { messages, config }) => DispatchResult::StartChat { messages, config },
|
|
Ok(WsRequest::Cancel) => DispatchResult::CancelChat,
|
|
Ok(WsRequest::Ping) => DispatchResult::Pong,
|
|
Ok(WsRequest::PermissionResponse { .. }) => DispatchResult::IgnoredPermission,
|
|
Ok(WsRequest::SideQuestion {
|
|
question,
|
|
context_messages,
|
|
config,
|
|
}) => DispatchResult::StartSideQuestion {
|
|
question,
|
|
context_messages,
|
|
config,
|
|
},
|
|
Err(err) => DispatchResult::ParseError(format!("Invalid request: {err}")),
|
|
}
|
|
}
|
|
|
|
/// The result of dispatching a message during an active chat session.
|
|
pub enum InnerDispatchResult {
|
|
/// A permission response was successfully resolved.
|
|
PermissionResolved,
|
|
/// Cancel the current chat session.
|
|
CancelChat,
|
|
/// Respond with a pong.
|
|
Pong,
|
|
/// Start a side question (can run concurrently with the chat).
|
|
StartSideQuestion {
|
|
question: String,
|
|
context_messages: Vec<crate::llm::types::Message>,
|
|
config: crate::llm::chat::ProviderConfig,
|
|
},
|
|
/// The message was not actionable during a chat (unknown type, etc.).
|
|
Ignored,
|
|
}
|
|
|
|
/// Parse a raw JSON text and dispatch it within an active chat session.
|
|
///
|
|
/// Permission responses are resolved against the `pending_perms` map.
|
|
/// Returns what action the caller should take.
|
|
pub fn dispatch_inner(
|
|
text: &str,
|
|
pending_perms: &mut HashMap<String, oneshot::Sender<PermissionDecision>>,
|
|
) -> InnerDispatchResult {
|
|
match serde_json::from_str::<WsRequest>(text) {
|
|
Ok(WsRequest::PermissionResponse {
|
|
request_id,
|
|
approved,
|
|
always_allow,
|
|
}) => {
|
|
if let Some(resp_tx) = pending_perms.remove(&request_id) {
|
|
let decision = resolve_permission(approved, always_allow);
|
|
let _ = resp_tx.send(decision);
|
|
}
|
|
InnerDispatchResult::PermissionResolved
|
|
}
|
|
Ok(WsRequest::Cancel) => InnerDispatchResult::CancelChat,
|
|
Ok(WsRequest::Ping) => InnerDispatchResult::Pong,
|
|
Ok(WsRequest::SideQuestion {
|
|
question,
|
|
context_messages,
|
|
config,
|
|
}) => InnerDispatchResult::StartSideQuestion {
|
|
question,
|
|
context_messages,
|
|
config,
|
|
},
|
|
_ => InnerDispatchResult::Ignored,
|
|
}
|
|
}
|
|
|
|
/// Map the `approved` and `always_allow` flags to a [`PermissionDecision`].
|
|
pub fn resolve_permission(approved: bool, always_allow: bool) -> PermissionDecision {
|
|
if always_allow {
|
|
PermissionDecision::AlwaysAllow
|
|
} else if approved {
|
|
PermissionDecision::Approve
|
|
} else {
|
|
PermissionDecision::Deny
|
|
}
|
|
}
|
|
|
|
/// Build a [`WsResponse::Error`] from an error message.
|
|
pub fn error_response(message: String) -> WsResponse {
|
|
WsResponse::Error { message }
|
|
}
|
|
|
|
/// Build the permission request forward message for the client.
|
|
pub fn permission_request_response(
|
|
request_id: &str,
|
|
tool_name: &str,
|
|
tool_input: &serde_json::Value,
|
|
) -> WsResponse {
|
|
WsResponse::PermissionRequest {
|
|
request_id: request_id.to_string(),
|
|
tool_name: tool_name.to_string(),
|
|
tool_input: tool_input.clone(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// ── dispatch_outer ──────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn dispatch_outer_chat() {
|
|
let json = r#"{"type":"chat","messages":[{"role":"user","content":"hi"}],"config":{"provider":"ollama","model":"m"}}"#;
|
|
let result = dispatch_outer(json);
|
|
assert!(matches!(result, DispatchResult::StartChat { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_outer_cancel() {
|
|
let result = dispatch_outer(r#"{"type":"cancel"}"#);
|
|
assert!(matches!(result, DispatchResult::CancelChat));
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_outer_ping() {
|
|
let result = dispatch_outer(r#"{"type":"ping"}"#);
|
|
assert!(matches!(result, DispatchResult::Pong));
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_outer_permission_response_ignored() {
|
|
let json = r#"{"type":"permission_response","request_id":"x","approved":true}"#;
|
|
let result = dispatch_outer(json);
|
|
assert!(matches!(result, DispatchResult::IgnoredPermission));
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_outer_side_question() {
|
|
let json = r#"{"type":"side_question","question":"what?","context_messages":[],"config":{"provider":"ollama","model":"m"}}"#;
|
|
let result = dispatch_outer(json);
|
|
assert!(matches!(result, DispatchResult::StartSideQuestion { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_outer_invalid_json() {
|
|
let result = dispatch_outer("not json");
|
|
match result {
|
|
DispatchResult::ParseError(msg) => {
|
|
assert!(msg.contains("Invalid request"));
|
|
}
|
|
_ => panic!("expected ParseError"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_outer_unknown_type() {
|
|
let result = dispatch_outer(r#"{"type":"bogus"}"#);
|
|
assert!(matches!(result, DispatchResult::ParseError(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_outer_missing_type() {
|
|
let result = dispatch_outer(r#"{"messages":[]}"#);
|
|
assert!(matches!(result, DispatchResult::ParseError(_)));
|
|
}
|
|
|
|
// ── dispatch_inner ──────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn dispatch_inner_permission_response_resolves() {
|
|
let (tx, rx) = oneshot::channel();
|
|
let mut perms = HashMap::new();
|
|
perms.insert("req-1".to_string(), tx);
|
|
|
|
let json = r#"{"type":"permission_response","request_id":"req-1","approved":true,"always_allow":false}"#;
|
|
let result = dispatch_inner(json, &mut perms);
|
|
assert!(matches!(result, InnerDispatchResult::PermissionResolved));
|
|
assert!(perms.is_empty());
|
|
assert_eq!(rx.blocking_recv().unwrap(), PermissionDecision::Approve);
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_inner_permission_response_always_allow() {
|
|
let (tx, rx) = oneshot::channel();
|
|
let mut perms = HashMap::new();
|
|
perms.insert("req-2".to_string(), tx);
|
|
|
|
let json = r#"{"type":"permission_response","request_id":"req-2","approved":true,"always_allow":true}"#;
|
|
let result = dispatch_inner(json, &mut perms);
|
|
assert!(matches!(result, InnerDispatchResult::PermissionResolved));
|
|
assert_eq!(rx.blocking_recv().unwrap(), PermissionDecision::AlwaysAllow);
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_inner_permission_response_deny() {
|
|
let (tx, rx) = oneshot::channel();
|
|
let mut perms = HashMap::new();
|
|
perms.insert("req-3".to_string(), tx);
|
|
|
|
let json = r#"{"type":"permission_response","request_id":"req-3","approved":false}"#;
|
|
let result = dispatch_inner(json, &mut perms);
|
|
assert!(matches!(result, InnerDispatchResult::PermissionResolved));
|
|
assert_eq!(rx.blocking_recv().unwrap(), PermissionDecision::Deny);
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_inner_permission_unknown_request_id() {
|
|
let mut perms: HashMap<String, oneshot::Sender<PermissionDecision>> = HashMap::new();
|
|
let json = r#"{"type":"permission_response","request_id":"unknown","approved":true}"#;
|
|
let result = dispatch_inner(json, &mut perms);
|
|
// Still returns PermissionResolved — the unknown ID is silently ignored.
|
|
assert!(matches!(result, InnerDispatchResult::PermissionResolved));
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_inner_cancel() {
|
|
let mut perms = HashMap::new();
|
|
let result = dispatch_inner(r#"{"type":"cancel"}"#, &mut perms);
|
|
assert!(matches!(result, InnerDispatchResult::CancelChat));
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_inner_ping() {
|
|
let mut perms = HashMap::new();
|
|
let result = dispatch_inner(r#"{"type":"ping"}"#, &mut perms);
|
|
assert!(matches!(result, InnerDispatchResult::Pong));
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_inner_side_question() {
|
|
let mut perms = HashMap::new();
|
|
let json = r#"{"type":"side_question","question":"what?","context_messages":[],"config":{"provider":"ollama","model":"m"}}"#;
|
|
let result = dispatch_inner(json, &mut perms);
|
|
assert!(matches!(
|
|
result,
|
|
InnerDispatchResult::StartSideQuestion { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_inner_chat_during_chat_ignored() {
|
|
let mut perms = HashMap::new();
|
|
let json = r#"{"type":"chat","messages":[],"config":{"provider":"ollama","model":"m"}}"#;
|
|
let result = dispatch_inner(json, &mut perms);
|
|
assert!(matches!(result, InnerDispatchResult::Ignored));
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_inner_invalid_json_ignored() {
|
|
let mut perms = HashMap::new();
|
|
let result = dispatch_inner("not json", &mut perms);
|
|
assert!(matches!(result, InnerDispatchResult::Ignored));
|
|
}
|
|
|
|
// ── resolve_permission ──────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn resolve_approve() {
|
|
assert_eq!(resolve_permission(true, false), PermissionDecision::Approve);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_deny() {
|
|
assert_eq!(resolve_permission(false, false), PermissionDecision::Deny);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_always_allow() {
|
|
assert_eq!(
|
|
resolve_permission(true, true),
|
|
PermissionDecision::AlwaysAllow
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_always_allow_overrides_denied() {
|
|
// always_allow=true should win even if approved=false
|
|
assert_eq!(
|
|
resolve_permission(false, true),
|
|
PermissionDecision::AlwaysAllow
|
|
);
|
|
}
|
|
|
|
// ── error_response ──────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn error_response_creates_error_variant() {
|
|
let resp = error_response("oops".to_string());
|
|
let json = serde_json::to_value(&resp).unwrap();
|
|
assert_eq!(json["type"], "error");
|
|
assert_eq!(json["message"], "oops");
|
|
}
|
|
|
|
// ── permission_request_response ─────────────────────────────────
|
|
|
|
#[test]
|
|
fn permission_request_response_creates_correct_variant() {
|
|
let input = serde_json::json!({"command": "rm -rf /"});
|
|
let resp = permission_request_response("req-1", "Bash", &input);
|
|
let json = serde_json::to_value(&resp).unwrap();
|
|
assert_eq!(json["type"], "permission_request");
|
|
assert_eq!(json["request_id"], "req-1");
|
|
assert_eq!(json["tool_name"], "Bash");
|
|
assert_eq!(json["tool_input"]["command"], "rm -rf /");
|
|
}
|
|
}
|