actrpc-core 0.1.0

Core types and traits for ActRPC.
Documentation
use actrpc_core::{
    action::{ActionKind, RequestedActionRecord, ResolvedActionRecord},
    error::ProtocolError,
    interception::{
        InterceptionPhase, InterceptionRequest, InterceptionResponse, InterceptorContinuation,
    },
    json_rpc::{
        JsonRpcBatch, JsonRpcId, JsonRpcMessage, JsonRpcNotification, JsonRpcParams,
        JsonRpcRequest, JsonRpcResponse, JsonRpcSingleMessage, JsonRpcSuccessResponse,
        JsonRpcVersion,
    },
    participant::{Participant, ParticipantType},
};
use serde_json::json;

#[test]
fn test_interception_request_phase_detection_single_messages() {
    let req = InterceptionRequest {
        origin: Participant {
            kind: ParticipantType::User,
            id: "cli-123".to_string(),
        },
        message: JsonRpcMessage::Single(JsonRpcSingleMessage::Request(JsonRpcRequest {
            jsonrpc: JsonRpcVersion::V2_0,
            id: JsonRpcId::Number(1.into()),
            method: "say_hello".to_string(),
            params: Some(JsonRpcParams::Array(vec![json!(1), json!("asd")])),
        })),
        resolved_action_history: Default::default(),
    };

    assert_eq!(req.phase(), Ok(InterceptionPhase::Outbound));
    assert!(!req.has_prior_actions());

    let notif = InterceptionRequest {
        origin: Participant {
            kind: ParticipantType::User,
            id: "cli-123".to_string(),
        },
        message: JsonRpcMessage::Single(JsonRpcSingleMessage::Notification(JsonRpcNotification {
            jsonrpc: JsonRpcVersion::V2_0,
            method: "ping".to_string(),
            params: None,
        })),
        resolved_action_history: Default::default(),
    };

    assert_eq!(notif.phase(), Ok(InterceptionPhase::Outbound));

    let resp = InterceptionRequest {
        origin: Participant {
            kind: ParticipantType::Orchestrator,
            id: "main".to_string(),
        },
        message: JsonRpcMessage::Single(JsonRpcSingleMessage::Response(JsonRpcResponse::Success(
            JsonRpcSuccessResponse {
                jsonrpc: JsonRpcVersion::V2_0,
                id: JsonRpcId::Number(1.into()),
                result: json!("world"),
            },
        ))),
        resolved_action_history: Default::default(),
    };

    assert_eq!(resp.phase(), Ok(InterceptionPhase::Inbound));
    assert!(!resp.has_prior_actions());
}

#[test]
fn test_interception_request_phase_detection_batch_outbound() {
    let req = InterceptionRequest {
        origin: Participant {
            kind: ParticipantType::User,
            id: "cli-123".to_string(),
        },
        message: JsonRpcMessage::Batch(JsonRpcBatch(vec![
            JsonRpcSingleMessage::Request(JsonRpcRequest {
                jsonrpc: JsonRpcVersion::V2_0,
                id: JsonRpcId::Number(1.into()),
                method: "sum".to_string(),
                params: Some(JsonRpcParams::Array(vec![json!(1), json!(2)])),
            }),
            JsonRpcSingleMessage::Notification(JsonRpcNotification {
                jsonrpc: JsonRpcVersion::V2_0,
                method: "ping".to_string(),
                params: None,
            }),
        ])),
        resolved_action_history: Default::default(),
    };

    assert_eq!(req.phase(), Ok(InterceptionPhase::Outbound));
}

#[test]
fn test_interception_request_phase_detection_batch_inbound() {
    let req = InterceptionRequest {
        origin: Participant {
            kind: ParticipantType::Orchestrator,
            id: "main".to_string(),
        },
        message: JsonRpcMessage::Batch(JsonRpcBatch(vec![
            JsonRpcSingleMessage::Response(JsonRpcResponse::Success(JsonRpcSuccessResponse {
                jsonrpc: JsonRpcVersion::V2_0,
                id: JsonRpcId::Number(1.into()),
                result: json!(3),
            })),
            JsonRpcSingleMessage::Response(JsonRpcResponse::Success(JsonRpcSuccessResponse {
                jsonrpc: JsonRpcVersion::V2_0,
                id: JsonRpcId::Number(2.into()),
                result: json!("ok"),
            })),
        ])),
        resolved_action_history: Default::default(),
    };

    assert_eq!(req.phase(), Ok(InterceptionPhase::Inbound));
}

#[test]
fn test_interception_request_phase_detection_batch_mixed_is_invalid() {
    let req = InterceptionRequest {
        origin: Participant {
            kind: ParticipantType::Orchestrator,
            id: "main".to_string(),
        },
        message: JsonRpcMessage::Batch(JsonRpcBatch(vec![
            JsonRpcSingleMessage::Request(JsonRpcRequest {
                jsonrpc: JsonRpcVersion::V2_0,
                id: JsonRpcId::Number(1.into()),
                method: "sum".to_string(),
                params: None,
            }),
            JsonRpcSingleMessage::Response(JsonRpcResponse::Success(JsonRpcSuccessResponse {
                jsonrpc: JsonRpcVersion::V2_0,
                id: JsonRpcId::Number(1.into()),
                result: json!(3),
            })),
        ])),
        resolved_action_history: Default::default(),
    };

    assert_eq!(req.phase(), Err(ProtocolError::MixedBatch));
}

#[test]
fn test_interception_request_has_prior_actions() {
    let req = InterceptionRequest {
        origin: Participant {
            kind: ParticipantType::Interceptor,
            id: "safety-v3".to_string(),
        },
        message: JsonRpcMessage::Single(JsonRpcSingleMessage::Request(JsonRpcRequest {
            jsonrpc: JsonRpcVersion::V2_0,
            id: JsonRpcId::Number(7.into()),
            method: "do_work".to_string(),
            params: None,
        })),
        resolved_action_history: vec![vec![ResolvedActionRecord {
            kind: ActionKind::from("log"),
            params: Some(json!({ "message": "checked" })),
            result: Err(ProtocolError::InvalidMessageDirection {
                reason: "noop".to_string(),
            }),
        }]],
    };

    assert!(req.has_prior_actions());
}

#[test]
fn test_interception_request_serde_minimal_skips_empty_resolved_action_history() {
    let req = InterceptionRequest {
        origin: Participant {
            kind: ParticipantType::User,
            id: "cli-123".to_string(),
        },
        message: JsonRpcMessage::Single(JsonRpcSingleMessage::Request(JsonRpcRequest {
            jsonrpc: JsonRpcVersion::V2_0,
            id: JsonRpcId::Number(1.into()),
            method: "subtract".to_string(),
            params: Some(JsonRpcParams::Array(vec![json!(4), json!(2)])),
        })),
        resolved_action_history: Default::default(),
    };

    let ser = serde_json::to_string(&req).unwrap();
    assert!(!ser.contains("resolved_action_history"));

    let de: InterceptionRequest = serde_json::from_str(&ser).unwrap();
    assert!(de.resolved_action_history.is_empty());
    assert_eq!(de.phase(), Ok(InterceptionPhase::Outbound));
}

#[test]
fn test_interception_response_serde_minimal_skips_empty_actions() {
    let decision = InterceptionResponse {
        actions: vec![],
        continuation: InterceptorContinuation::Stop,
    };

    let ser = serde_json::to_string(&decision).unwrap();
    assert_eq!(ser, r#"{"continuation":"stop"}"#);

    let de: InterceptionResponse = serde_json::from_str(&ser).unwrap();
    assert!(de.actions.is_empty());
    assert!(de.should_stop());
    assert!(!de.should_reinvoke());
}

#[test]
fn test_interception_response_helpers_with_actions() {
    let response = InterceptionResponse {
        actions: vec![RequestedActionRecord {
            kind: ActionKind::from("notify"),
            params: Some(json!({ "channel": "audit" })),
        }],
        continuation: InterceptorContinuation::Reinvoke,
    };

    assert!(response.has_actions());
    assert!(response.should_reinvoke());
    assert!(!response.should_stop());

    let ser = serde_json::to_string(&response).unwrap();
    let de: InterceptionResponse = serde_json::from_str(&ser).unwrap();
    assert_eq!(de, response);
}