huskies: merge 612_story_extract_ws_service
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
//! 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 /");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user