Skip to main content

meerkat_runtime/
peer_handling_mode.rs

1//! Peer handling-mode validation — reject handling_mode on response progress conventions.
2//!
3//! Rules:
4//! - handling_mode is FORBIDDEN for: PeerInput(ResponseProgress)
5//! - handling_mode is ALLOWED for: PeerInput(Message), PeerInput(Request), PeerInput(ResponseTerminal), PeerInput(no convention)
6//! - Non-peer inputs are always accepted (validation is a no-op)
7
8use crate::input::{Input, PeerConvention};
9
10/// Errors from peer handling-mode validation.
11#[derive(Debug, Clone, thiserror::Error)]
12#[non_exhaustive]
13pub enum PeerHandlingModeError {
14    /// handling_mode is not allowed on ResponseProgress peer inputs.
15    #[error("handling_mode is forbidden on ResponseProgress peer inputs")]
16    ForbiddenForResponseProgress,
17}
18
19/// Validate that a peer input does not carry handling_mode on response progress.
20pub fn validate_peer_handling_mode(input: &Input) -> Result<(), PeerHandlingModeError> {
21    let Input::Peer(peer) = input else {
22        return Ok(());
23    };
24    if peer.handling_mode.is_none() {
25        return Ok(());
26    }
27    match &peer.convention {
28        Some(PeerConvention::ResponseProgress { .. }) => {
29            Err(PeerHandlingModeError::ForbiddenForResponseProgress)
30        }
31        _ => Ok(()),
32    }
33}
34
35#[cfg(test)]
36#[allow(clippy::unwrap_used)]
37mod tests {
38    use super::*;
39    use crate::input::*;
40    use chrono::Utc;
41    use meerkat_core::lifecycle::InputId;
42    use meerkat_core::types::HandlingMode;
43
44    fn make_header() -> InputHeader {
45        InputHeader {
46            id: InputId::new(),
47            timestamp: Utc::now(),
48            source: InputOrigin::Peer {
49                peer_id: "peer-1".into(),
50                display_identity: None,
51                runtime_id: None,
52            },
53            durability: InputDurability::Durable,
54            visibility: InputVisibility::default(),
55            idempotency_key: None,
56            supersession_key: None,
57            correlation_id: None,
58        }
59    }
60
61    #[test]
62    fn response_progress_with_handling_mode_rejected() {
63        let input = Input::Peer(PeerInput {
64            header: make_header(),
65            convention: Some(PeerConvention::ResponseProgress {
66                request_id: "r".into(),
67                phase: ResponseProgressPhase::InProgress,
68            }),
69            body: "working".into(),
70            payload: Some(serde_json::json!({"progress": "working"})),
71            blocks: None,
72            handling_mode: Some(HandlingMode::Queue),
73        });
74        let err = validate_peer_handling_mode(&input).unwrap_err();
75        assert!(matches!(
76            err,
77            PeerHandlingModeError::ForbiddenForResponseProgress
78        ));
79    }
80
81    #[test]
82    fn response_terminal_with_handling_mode_accepted() {
83        let input = Input::Peer(PeerInput {
84            header: make_header(),
85            convention: Some(PeerConvention::ResponseTerminal {
86                request_id: "r".into(),
87                status: ResponseTerminalStatus::Completed,
88            }),
89            body: "done".into(),
90            payload: Some(serde_json::json!({"ok": true})),
91            blocks: None,
92            handling_mode: Some(HandlingMode::Steer),
93        });
94        assert!(validate_peer_handling_mode(&input).is_ok());
95    }
96
97    #[test]
98    fn response_terminal_with_queue_handling_mode_accepted() {
99        let input = Input::Peer(PeerInput {
100            header: make_header(),
101            convention: Some(PeerConvention::ResponseTerminal {
102                request_id: "r".into(),
103                status: ResponseTerminalStatus::Completed,
104            }),
105            body: "done".into(),
106            payload: Some(serde_json::json!({"ok": true})),
107            blocks: None,
108            handling_mode: Some(HandlingMode::Queue),
109        });
110        assert!(validate_peer_handling_mode(&input).is_ok());
111    }
112
113    #[test]
114    fn message_with_handling_mode_accepted() {
115        let input = Input::Peer(PeerInput {
116            header: make_header(),
117            convention: Some(PeerConvention::Message),
118            body: "hi".into(),
119            payload: None,
120            blocks: None,
121            handling_mode: Some(HandlingMode::Queue),
122        });
123        assert!(validate_peer_handling_mode(&input).is_ok());
124    }
125
126    #[test]
127    fn request_with_handling_mode_accepted() {
128        let input = Input::Peer(PeerInput {
129            header: make_header(),
130            convention: Some(PeerConvention::Request {
131                request_id: "r".into(),
132                intent: "i".into(),
133            }),
134            body: "do it".into(),
135            payload: Some(serde_json::json!({"subject": "x"})),
136            blocks: None,
137            handling_mode: Some(HandlingMode::Steer),
138        });
139        assert!(validate_peer_handling_mode(&input).is_ok());
140    }
141
142    #[test]
143    fn no_convention_with_handling_mode_accepted() {
144        let input = Input::Peer(PeerInput {
145            header: make_header(),
146            convention: None,
147            body: "hi".into(),
148            payload: None,
149            blocks: None,
150            handling_mode: Some(HandlingMode::Queue),
151        });
152        assert!(validate_peer_handling_mode(&input).is_ok());
153    }
154
155    #[test]
156    fn peer_without_handling_mode_always_accepted() {
157        for convention in [
158            Some(PeerConvention::Message),
159            Some(PeerConvention::Request {
160                request_id: "r".into(),
161                intent: "i".into(),
162            }),
163            Some(PeerConvention::ResponseProgress {
164                request_id: "r".into(),
165                phase: ResponseProgressPhase::InProgress,
166            }),
167            Some(PeerConvention::ResponseTerminal {
168                request_id: "r".into(),
169                status: ResponseTerminalStatus::Completed,
170            }),
171            None,
172        ] {
173            let input = Input::Peer(PeerInput {
174                header: make_header(),
175                convention,
176                body: "hi".into(),
177                payload: None,
178                blocks: None,
179                handling_mode: None,
180            });
181            assert!(
182                validate_peer_handling_mode(&input).is_ok(),
183                "should accept peer without handling_mode"
184            );
185        }
186    }
187
188    #[test]
189    fn non_peer_input_always_accepted() {
190        let input = Input::Prompt(PromptInput {
191            header: InputHeader {
192                id: InputId::new(),
193                timestamp: Utc::now(),
194                source: InputOrigin::Operator,
195                durability: InputDurability::Durable,
196                visibility: InputVisibility::default(),
197                idempotency_key: None,
198                supersession_key: None,
199                correlation_id: None,
200            },
201            text: "hi".into(),
202            blocks: None,
203            typed_turn_appends: Vec::new(),
204            turn_metadata: None,
205        });
206        assert!(validate_peer_handling_mode(&input).is_ok());
207    }
208}